Compare commits

..

80 Commits

Author SHA1 Message Date
Juan Camilo
a06ea87545 fix(security): remove unauthenticated file-based permission polling
Remove the legacy file-based permission polling from useSwarmPermissionPoller
that read from ~/.claude/teams/{name}/permissions/resolved/ — an unauthenticated
directory where any local process could forge approval files to auto-approve
tool uses for swarm teammates.

The file polling was dead code:
- The useSwarmPermissionPoller() hook was never mounted by any component
- resolvePermission() (the file writer) was never imported outside its module
- Permission responses are delivered exclusively via the mailbox system:
  Leader: sendPermissionResponseViaMailbox() → writeToMailbox()
  Worker: useInboxPoller → processMailboxPermissionResponse()

Changes:
- Remove file polling loop, processResponse(), and React hook imports from
  useSwarmPermissionPoller.ts (now a pure callback registry module)
- Mark 7 file-based functions as @deprecated in permissionSync.ts
- Add 4 regression tests verifying the removal

No exported functions removed — only deprecated. All 5 consumer modules
verified: they import only mailbox-based functions that remain unchanged.
2026-04-20 14:38:57 +02:00
Juan Camilo
c0354e8699 fix(security): harden project settings trust boundary + MCP sanitization
- Sanitize MCP tool result text with recursivelySanitizeUnicode() to prevent
  Unicode injection via malicious MCP servers (tool definitions and prompts
  were already sanitized, but tool call results were not)
- Read sandbox.enabled only from trusted settings sources (user, local, flag,
  policy) — exclude projectSettings to prevent malicious repos from silently
  disabling the sandbox via .claude/settings.json
- Disable git hooks in plugin marketplace clone/pull/submodule operations
  with core.hooksPath=/dev/null to prevent code execution from cloned repos
- Remove ANTHROPIC_FOUNDRY_API_KEY from SAFE_ENV_VARS to prevent credential
  injection from project-scoped settings without trust verification
- Add ssrfGuardedLookup to WebFetch HTTP requests to block DNS rebinding
  attacks that could reach cloud metadata or internal services

Security: closes trust boundary gap where project settings could override
security-critical configuration. Follows the existing pattern established
by hasAllowBypassPermissionsMode() which already excludes projectSettings.

Co-authored-by: auriti <auriti@users.noreply.github.com>
2026-04-20 14:11:46 +02:00
Zartris
4d4fb2880e fix: rename .claude.json to .openclaude.json with legacy fallback (#582)
* fix: rename .claude.json to .openclaude.json with legacy fallback

Rename the global config file from ~/.claude.json to ~/.openclaude.json,
following the same migration pattern as the config directory
(~/.claude → ~/.openclaude).

- getGlobalClaudeFile() now prefers .openclaude.json; falls back to
  .claude.json only if the legacy file exists and the new one does not
- Add .openclaude.json to filesystem permissions allowlist (keep
  .claude.json for legacy file protection)
- Update all comment/string references from ~/.claude.json to
  ~/.openclaude.json across 12 files

New installs get .openclaude.json from the start. Existing users
continue using .claude.json until they rename it (or a future explicit
migration).

* test: add unit tests for getGlobalClaudeFile migration branches

Covers the three cases:
- new install (neither file exists) → .openclaude.json
- existing user (only legacy .claude.json exists) → .claude.json
- migrated user (both files exist) → .openclaude.json

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-20 17:13:09 +08:00
Zartris
fdef4a1b4c feat: native Anthropic API mode for Claude models on GitHub Copilot (#579)
* feat: native Anthropic API mode for Claude models on GitHub Copilot

When using Claude models through GitHub Copilot, automatically switch from
the OpenAI-compatible shim to Anthropic's native messages API format.

The Copilot proxy (api.githubcopilot.com) supports Anthropic's native API
for Claude models. This enables cache_control blocks to be sent and
honoured, allowing explicit prompt caching control (as opposed to relying
solely on server-side auto-caching).

Changes:
- Add isGithubNativeAnthropicMode() in providers.ts that auto-enables when
  the resolved model starts with "claude-" and the GitHub provider is active
- Create a native Anthropic client in client.ts using the GitHub base URL
  and Bearer token authentication when native mode is detected
- Enable prompt caching in claude.ts for native GitHub mode so cache_control
  blocks are sent (previously only allowed for firstParty/bedrock/vertex)
- CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 env var to force native mode for any
  model

Benefits:
- Proper Anthropic message format (no lossy OpenAI translation)
- Explicit cache_control blocks for fine-grained caching control
- Potentially better Claude model behaviour with native format

Related: #515

* fix: scope force flag to Claude models and add isGithubNativeAnthropicMode tests

- CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 now returns false for non-Claude models
  (force flag still useful for aliases like 'github:copilot' with no model
  resolved yet, where it returns true when model is empty)
- Add 7 focused tests covering mode detection: off without GitHub provider,
  auto-detect via OPENAI_MODEL and resolvedModel, non-Claude model rejection,
  and force-flag behaviour for claude/non-claude/no-model cases

* fix: detect github:copilot:claude- compound format, remove force flag

OPENAI_MODEL for GitHub Copilot uses the format 'github:copilot:MODEL'
(e.g. 'github:copilot:claude-sonnet-4'), which does not start with 'claude-'.
Auto-detection now handles both bare model names and the compound format.

The CLAUDE_CODE_GITHUB_ANTHROPIC_API force flag is removed: with proper
compound-format detection there is no remaining gap it could fill, and
keeping a broad override flag without a concrete use case invites misuse.

Tests updated to cover the compound format, generic alias (false), and
non-Claude compound model (github:copilot:gpt-4o → false).

* fix: use includes('claude-') for model detection, remove force flag

Detection was broken for the standard GitHub Copilot compound format
'github:copilot:claude-sonnet-4' which does not start with 'claude-'.
Using includes('claude-') handles bare names, compound names, and any
future variants without needing updates.

The CLAUDE_CODE_GITHUB_ANTHROPIC_API force flag is removed as it was
a workaround for the broken detection, not a genuine use case.

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
2026-04-20 16:34:58 +08:00
nehan
4cb963e660 feat(api): improve local provider reliability with readiness and self-healing (#738)
* feat(api): classify openai-compatible provider failures

* Update src/services/api/providerConfig.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(api): harden openai-compatible diagnostics and env fallback

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix openaiShim duplicate requests and diagnostics

* remove unused url from http failure classifier

* dedupe env diagnostic warnings

* Remove hardcoded URLs from OpenAI error tests

Removed hardcoded URLs from network failure classification tests.

* Update providerConfig.envDiagnostics.test.ts

* fix(openai-shim): return successful responses and restore localhost classifier tests

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(provider): add truthful local generation readiness checks

Implement Phase 2 provider readiness behavior by adding structured Ollama generation probes, wiring setup flows to readiness states, extending system-check with generation readiness output, and updating focused tests.

* feat(api): add local self-healing fallback retries

Implement Phase 3 self-healing behavior for local OpenAI-compatible providers: retry base URL fallbacks for localhost resolution and endpoint mismatches, plus capability-gated toolless retry for tool-incompatible local models; include diagnostics and focused tests.

* fix(api): address review blockers for local provider reliability

* Update src/utils/providerDiscovery.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: harden readiness probes and cross-platform test stability

* fix: refresh toolless retry payload and stabilize osc clipboard test

* fix: harden Ollama readiness parsing and redact provider URLs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 16:24:02 +08:00
github-actions[bot]
b09972f223 chore(main): release 0.5.2 (#781)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 15:25:42 +08:00
Kevin Codex
336ddcc50d fix(api): replace phrase-based reasoning sanitizer with tag-based filter (#779)
Reasoning models (MiniMax M2.7, GLM-4.5/5, DeepSeek, Kimi K2) inline
chain-of-thought inside <think>...</think> tags in the content field
rather than using the reasoning_content channel. The prior phrase-matching
sanitizer (looksLikeLeakedReasoningPrefix) only caught English-prose
preambles like "I should"/"the user asked", missed tag-based leaks
entirely, and risked false-stripping legitimate assistant output.

Replace with a structural tag-based approach (same pattern as hermes-agent):

- createThinkTagFilter() — streaming state machine that buffers partial
  tags across SSE delta boundaries (<th| + |ink>), so tags split mid-chunk
  still parse correctly.
- stripThinkTags() — whole-text cleanup for non-streaming responses and
  as a safety net. Handles closed pairs, unterminated opens at block
  boundaries, and orphan tags.
- Recognizes think, thinking, reasoning, thought, REASONING_SCRATCHPAD
  case-insensitively, including tags with attributes.
- False-negative bias: flush() discards buffered partial tags at stream
  end rather than leaking them.

Existing phrase-based shim tests updated to exercise the actual <think>
tag leak. Added regression tests confirming legitimate prose starting
with "I should..." is preserved (the old sanitizer's main false-positive).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:18:58 +08:00
github-actions[bot]
c0b8a59a23 chore(main): release 0.5.1 (#776)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 12:47:40 +08:00
Kevin Codex
aab489055c fix: require trusted approval for sandbox override (#778) 2026-04-20 12:01:44 +08:00
Kevin Codex
7002cb302b fix: enforce Bash path constraints after sandbox allow (#777) 2026-04-20 11:46:24 +08:00
Kevin Codex
739b8d1f40 fix: enforce MCP OAuth callback state before errors (#775) 2026-04-20 09:36:05 +08:00
github-actions[bot]
f166ec1a4e chore(main): release 0.5.0 (#758)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 08:30:58 +08:00
Kevin Codex
13e9f22a83 feat: mask provider api key input (#772) 2026-04-20 08:25:22 +08:00
Kevin Codex
f828171ef1 fix: allow provider recovery during startup (#765) 2026-04-20 06:46:05 +08:00
Allan Almeida
e6e8d9a248 feat: add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization (#770)
When set, disables strict schema normalization for non-Gemini providers.
Useful for OpenAI-compatible endpoints that reject MCP tools with complex
optional params (e.g. list[dict]) with "Extra required key ... supplied"
errors.
2026-04-20 06:45:01 +08:00
Sreedhar Busanelli
2c98be7002 fix: remove cached mcpClient in diagnostic tracking to prevent stale references (#727)
* fix: remove cached mcpClient in diagnostic tracking to prevent stale references

Resolves TODO comment about not caching the connected mcpClient since it can change.

Changes:
- Remove cached mcpClient field from DiagnosticTrackingService
- Add currentMcpClients storage to track active clients
- Update beforeFileEdited, getNewDiagnostics, and ensureFileOpened to accept client parameter
- Add backward-compatible methods to maintain existing API
- Update all callers to use new methods
- Add comprehensive test coverage

This prevents using stale MCP client references during reconnections,
making diagnostic tracking more reliable.

Fixes #TODO

* docs: add my contributions section to README

Add fork-specific section highlighting:
- Diagnostic tracking enhancement (PR #727)
- Technical skills demonstrated
- Links to original project and my work
- Professional contribution showcase

* revert: remove README.md contributions section to comply with reviewer request

- Remove 'My Fork & Contributions' section from README.md
- Keep README.md focused on original project documentation
- Maintain clean, project-focused README as requested by reviewer
2026-04-19 09:02:52 +08:00
3kin0x
b786b765f0 fix(api): drop orphan tool results to satisfy strict role sequence (#745)
* fix(api): drop orphan tool results to satisfy Mistral/OpenAI strict role sequence

* test: add test for orphan tool results and restore gemini comments
2026-04-19 08:57:14 +08:00
bpawnzz
55c5f262a9 fix: use raw context window for auto-compact percentage display (#748)
Problem: After auto-compaction with DeepSeek models (e.g., deepseek-chat),
the status line displayed ~16% remaining until next auto-compact, but users
expected ~30% (since compaction reduces usage to roughly half of the full
128k context).

Root cause: calculateTokenWarningState() used the auto-compaction threshold
(effectiveContextWindow - 13k buffer) as the denominator for percentLeft.
For DeepSeek-chat:
- Raw context: 128,000
- Effective: 119,808 (128k - 8,192 output reservation)
- Threshold: 106,808 (effective - 13k buffer)
At 90k usage:
  - Old: (106,808 - 90k) / 106,808 ≈ 16%
  - Expected: (128,000 - 90k) / 128,000 ≈ 30%

Fix: Change percentLeft calculation to use raw context window from
getContextWindowForModel() as denominator, while keeping threshold-based
warnings/triggers unchanged. This makes the displayed percentage show
remaining capacity relative to the model's full context size.

Impact:
- UI now shows correct % of total context remaining
- Auto-compaction trigger point unchanged (still ~90% of effective window)
- All other threshold calculations unaffected

Testing:
- Manual verification: DeepSeek-chat at 90k tokens shows 30% remaining (was 16%)
- Manual verification: Threshold still triggers at ~106k tokens
- Build succeeds: npm run build
- No breaking changes: Callers only depend on percentLeft for display; threshold logic unchanged

Fixes the user-reported discrepancy for DeepSeek and other OpenAI-compatible models.
2026-04-19 08:55:41 +08:00
Kagura
002a8f1f6d fix(mcp): sync required array with properties in tool schemas (#754)
* fix(mcp): sync required array with properties in tool schemas

MCP servers can emit schemas where the required array contains keys
not present in properties. This causes API 400 errors:
"Extra required key 'X' supplied."

- Add sanitizeSchemaRequired() to filter required arrays
- Apply it to MCP tool inputJSONSchema before sending to API
- Also fix filterSwarmFieldsFromSchema to update required after
  removing properties

Fixes #525

* test: add MCP schema required sanitization test
2026-04-19 06:44:25 +08:00
dhenuh
3d1979ff06 fix(help): prevent /help tab crash from undefined descriptions (#732)
- Guard formatDescriptionWithSource() so missing command descriptions become ''
- Harden truncate helpers to accept undefined text/path safely
- Add regression tests covering undefined input cases
2026-04-19 06:38:44 +08:00
lunamonke
b0d9fe7112 Provider loading fix (#623)
* add mistral and gemini provider type for profile provider field

* load latest locally selected

* env variables take precedence over json save

* add gemini context windows and fix gemini defaulting for env

* load on startup fix

* fix failing tests

* clarify test message

* fix variable mismatches

* fix failing test

* delete keys and set profile.apiKey for mistral and gemini

* switch model as well when switching provider

* set model when adding a new model
2026-04-18 01:46:20 +08:00
github-actions[bot]
651123db1f chore(main): release 0.4.0 (#704)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-17 19:36:32 +08:00
emsanakhchivan
34246635fb fix(ui): show correct endpoint URL in intro screen for custom Anthropic endpoints (#735)
Previously, the startup intro screen always displayed
'https://api.anthropic.com' as the endpoint for Anthropic provider,
even when a custom endpoint was configured via ANTHROPIC_BASE_URL.

This fix reads ANTHROPIC_BASE_URL from environment and displays the
actual configured endpoint, providing accurate information to users
about where their API requests will be sent (proxy gateways, staging,
custom Anthropic-compatible APIs).

Also adds isLocal detection for local endpoints to show appropriate
visual indicator in the startup banner.

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-17 19:06:47 +08:00
regisksc
43ac6dba75 feat: add Alibaba Coding Plan (DashScope) provider support (#509)
* feat: add Alibaba Coding Plan provider presets

* fix: add DashScope presets to ProviderManager UI selection list

* feat: read DASHSCOPE_API_KEY env var for DashScope provider presets

* adds regression testing for alibaba models

* docs: add time descriptive comment

* feat(dashscope): add qwen3.6-plus model support

* fix(dashscope): remove MiniMax-M2.5 entries to prevent future key conflicts
2026-04-17 19:06:21 +08:00
nehan
80a00acc2c feat(api): classify openai-compatible provider failures (#708)
* feat(api): classify openai-compatible provider failures

* Update src/services/api/providerConfig.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(api): harden openai-compatible diagnostics and env fallback

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix openaiShim duplicate requests and diagnostics

* remove unused url from http failure classifier

* dedupe env diagnostic warnings

* Remove hardcoded URLs from OpenAI error tests

Removed hardcoded URLs from network failure classification tests.

* Update providerConfig.envDiagnostics.test.ts

* fix(openai-shim): return successful responses and restore localhost classifier tests

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 18:01:40 +08:00
Andrei Parshin
eed77e6579 fix: prevent crash in commands tab when description is undefined (#730)
This commit fixes a crash in the CLI that occurs when navigating to the /help commands tab. The issue happens because the truncate function receives an undefined value for the str parameter if a command lacks a description, causing the .indexOf() method to throw an exception. To resolve this, an early return check was added at the beginning of the function to gracefully handle empty values and prevent the UI from crashing.
2026-04-17 13:57:40 +08:00
guanjiawei
b280c740a6 fix serialize git worktree mutations and forward teammate PATH (#721) 2026-04-16 21:44:56 +08:00
guanjiawei
2ff5710329 fix retry Codex and OpenAI fetches via proxy-aware helper (#720) 2026-04-16 21:42:14 +08:00
emsanakhchivan
d6f5130c20 fix: focus "Done" option after completing provider manager actions (#718)
When returning to the provider manager menu after completing an action
(add, edit, delete, set active, etc.), the cursor now lands on "Done"
instead of the first option ("Add provider"). This prevents accidental
re-entry into the same action if the user presses Enter quickly.

On initial /provider invocation, the cursor still starts on the first
option ("Add provider") as expected.

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-16 21:39:13 +08:00
Rubens Oliveira
d32a2a1329 docs: add Ollama launch integration documentation (#716)
Document the new `ollama launch openclaude` command as a shortcut
for running OpenClaude through a local Ollama instance. This is
now supported in Ollama's launch system and handles all environment
variable setup automatically — no manual env vars needed.

Changes:
- README.md: Add "Using Ollama's launch command" section after the
  manual Ollama env var setup, and update the provider table to
  list `ollama launch` as a setup path for Ollama
- docs/advanced-setup.md: Add `ollama launch` as the recommended
  method at the top of the Ollama section, with the manual env var
  approach kept below as an alternative
2026-04-16 21:23:44 +08:00
henriquepasquini2
fbcd928f7f feat(vscode): add full chat interface to OpenClaude extension (#608)
Add a Claude Code-like chat experience to the VS Code extension with:
- Streaming chat panel (sidebar + editor tab) with markdown rendering
- Tool use visualization with inline diffs (replace/with display)
- Session history browser with JSONL transcript parsing
- Thinking block indicator with elapsed time and token count
- Clickable file paths that open in the editor
- Permission mode setting (acceptEdits default)
- Multi-turn conversation support via NDJSON stream-json protocol
- Status bar with live activity indicators
- Ctrl+Shift+L keybinding to open chat panel

Made-with: Cursor

Co-authored-by: henriquepasquini2 <henriquepasquini2@users.noreply.github.com>
2026-04-16 05:04:31 +08:00
Yakout
77083d769b Fix/MCP exposure v2 TODO's (#675)
* fix: OAuth tokens secure storage for Windows & Linux

* fix(mcp): MCP Tool Re-exposure & Strict Input Validation

Fixes the MCP re-exposure bug by correctly handling tool deduplication, input validation with Ajv, and structured output (including images). Also disables experimental API betas by default to prevent 500 errors on external accounts.

* fix(mcp): skip official registry prefetch in non-first-party mode

Prevents unnecessary calls to Anthropic's MCP registry when using other API providers.

* fix(cli): disable experimental API betas by default

This prevents 500 errors from Anthropic's API when tool-calling with non-Anthropic accounts or models that don't support certain beta features.

* fix: issues raised in the PR review for #675
2026-04-16 05:03:06 +08:00
emsanakhchivan
b66633ea4d Feat/multi model provider support (#692)
* test: add tests for provider model env updates and multi-model profiles

Add comprehensive tests covering:
- OPENAI_MODEL/ANTHROPIC_MODEL env updates on provider activation
- Cross-provider type switches (openai ↔ anthropic) clearing stale env
- Multi-model profile activation using only the first model for env vars
- Model options cache population from comma-separated model lists
- getProfileModelOptions generating correct ModelOption arrays

* feat: multi-model provider support and model auto-switch

Support comma-separated model names in provider profiles (e.g.
"glm-4.7, glm-4.7-flash"). The first model is used as default on
activation; all models appear in the /model picker for easy switching.

When switching active providers, the session model now automatically
updates to the new provider's first model. The multi-model list is
preserved across switches and /model selections.

Changes:
- Add parseModelList, getPrimaryModel, hasMultipleModels utilities
  with full test coverage (19 tests)
- Use getPrimaryModel when applying profiles to process.env so only
  the primary model is set in OPENAI_MODEL/ANTHROPIC_MODEL
- Update ProviderManager UI to hint at multi-model syntax and show
  model count in provider list summaries
- Populate model options cache from multi-model profiles on activation
  so all models appear in /model picker regardless of base URL type
- Guard persistActiveProviderProfileModel against overwriting
  comma-separated lists: models already in the profile are session
  selections, not profile edits
- Set AppState.mainLoopModel to the actual model string on provider
  switch so Anthropic profiles use the configured model instead of
  falling back to the built-in default

* fix: only show profile models when provider profile env is applied

Guard the profile model picker options behind a
PROFILE_ENV_APPLIED check. getActiveProviderProfile() has a
?? profiles[0] fallback that returns the first profile even when
no profile is explicitly active, causing users with inactive
profiles to lose all standard model options (Opus, Haiku, etc.)
from the /model picker.

* fix: show all model names for profiles with 3 or fewer models

Instead of a summary format for multi-model profiles, display all
model names when there are 3 or fewer. Only use the "+ N more"
format for profiles with 4+ models.

* fix: preserve standard model options in picker alongside profile models

The previous implementation used an early return that replaced all
standard picker options (Opus, Haiku, Sonnet for Anthropic; Codex/GPT
models for OpenAI) with only the profile's custom models.

Changes:
- Collect profile models into a shared array instead of early returning
- Append profile models to firstParty path (Opus + Haiku + Sonnet + custom)
- Append profile models to PAYG 3P path (Codex + Sonnet + Opus + Haiku + custom)
- Guard collection behind PROFILE_ENV_APPLIED to avoid ?? profiles[0] fallback

Fixes review feedback: standard models are no longer hidden when a
provider profile with custom models is active. Users see both the
standard options and their profile's models.

---------

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-16 05:01:55 +08:00
ArkhAngelLifeJiggy
51191d6132 feat: add NVIDIA NIM and MiniMax provider support (#552)
* feat: add NVIDIA NIM and MiniMax provider support

- Add nvidia-nim and minimax to --provider CLI flag
- Add model discovery for NVIDIA NIM (160+ models) and MiniMax
- Update /model picker to show provider-specific models
- Fix provider detection in startup banner
- Update .env.example with new provider options

Supported providers:
- NVIDIA NIM: https://integrate.api.nvidia.com/v1
- MiniMax: https://api.minimax.io/v1

* fix: resolve conflict in StartupScreen (keep NVIDIA/MiniMax + add Codex detection)

* fix: resolve providerProfile conflict (add imports from main, keep NVIDIA/MiniMax)

* fix: revert providerSecrets to match main (NVIDIA/MiniMax handled elsewhere)

* fix: add context window entries for NVIDIA NIM and new MiniMax models

* fix: use GLM-5 as NVIDIA NIM default and MiniMax-M2.5 for consistency

* fix: address remaining review items - add GLM/Kimi context entries, max output tokens, fix .env.example, revert to Nemotron default

* fix: filter NVIDIA NIM picker to chat/instruct models only, set provider-specific API keys from saved profiles

* chore: add more NVIDIA NIM context window entries for popular models

* fix: address remaining non-blocking items - fix base model, clear provider API keys on profile switch
2026-04-15 20:26:13 +08:00
Jeevan Mohan Pawar
6b2121da12 fix(models): prevent /models crash from non-string saved model values (#691)
* fix(models): guard GitHub default model setting against non-string values

* test(models): avoid brittle GitHub default assertion in model guard test
2026-04-15 19:47:02 +08:00
dhenuh
c207cdbdcc ci: skip release-please on fork repositories (#701) 2026-04-15 19:46:39 +08:00
Nourrisse Florian
a00b7928de fix: strip comments before scanning for missing imports (#676)
* fix: strip comments before scanning for missing imports

The scanForMissingImports regex matched require() and import() patterns
inside JSDoc comments, causing false-positive missing module detection.
A documented path like `require('./commands/proactive.js')` in a comment
was resolved from the wrong directory, marked as missing, then the global
onResolve handler intercepted ALL imports of that specifier — including
valid ones — replacing them with truthy noop stubs that broke runtime.

Strip block (/* */) and line (//) comments from source before scanning.

* fix: repair 10 pre-existing test failures

- promptIdentity.test.ts: define MACRO global (ISSUES_EXPLAINER etc.)
  for test mode where Bun.define build-time replacements aren't active
- context.test.ts: clear OPENAI_MODEL env var in each test — the user's
  environment (e.g. OPENAI_MODEL=github_copilot/gpt-5.4) polluted the
  provider-qualified lookup, returning wrong context windows
- openclaudePaths.test.ts: set CLAUDE_CONFIG_DIR to force .openclaude
  path when ~/.openclaude doesn't exist on the test machine
2026-04-15 19:42:26 +08:00
3kin0x
12dd3755c6 feat: add ripgrep to Dockerfile for faster file searching (#688) 2026-04-15 19:42:06 +08:00
dhenuh
114f772a4a tests: avoid global fetch mutation in GitHub device flow tests (#702) 2026-04-15 19:38:46 +08:00
Kevin Codex
7187fc007a docs: add Star History chart to README (#686)
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-15 02:38:05 +08:00
Fexiven
0ed50ccfe7 Fix Docker deployment (#685)
* feat: add Docker image build and push to GHCR on release

Add Dockerfile (multi-stage build with node:22-slim) and a new docker
job in the release workflow that builds and pushes to ghcr.io when
release-please creates a tag.

* feat(docker): run as non-root user and add smoke test

Run the container as a non-root appuser to reduce blast radius.
Add a smoke test step that runs --version before pushing to GHCR.

* fix(docker): use existing node user instead of creating appuser

Closes #681
2026-04-15 01:22:08 +08:00
github-actions[bot]
131b31bf0e chore(main): release 0.3.0 (#661)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-14 19:24:31 +08:00
Nourrisse Florian
c1beea9867 feat: open useful USER_TYPE-gated features to all users (#644)
* feat: open useful USER_TYPE-gated features to all users

Remove 13 process.env.USER_TYPE === 'ant' gates that restricted useful
features to Anthropic employees. These features work without Anthropic
infrastructure and are now available to all open-build users.

Features opened:
- Agent nesting (sub-agents can spawn sub-agents)
- Effort 'max' persistence in settings
- Plan mode interview phase (controlled by feature flags)
- Sandbox disabled commands (via ~/.claude/feature-flags.json)
- All tips visible to all users (plan mode, feedback, shift-tab)

Simplified:
- Fullscreen defaults to off (use /config to enable)
- Explore agent always uses haiku model
- Plan mode tool uses conservative prompt for all users

Continues the USER_TYPE cleanup from #637 (dead code) and builds
on #639 (local feature flags).

* fix: address Copilot review comments — remove residual dead code

1. bridgeConfig.ts: ungate bridge override functions — return env vars
   directly instead of hardcoded undefined
2. bridgeMain.ts + initReplBridge.ts: ungate sessionIngressUrl — read
   CLAUDE_BRIDGE_SESSION_INGRESS_URL without USER_TYPE check
3. tools.ts: remove dead ConfigTool/TungstenTool imports, narrow
   eslint-disable scope, stub REPLTool/SuggestBackgroundPRTool to null
4. readOnlyValidation.ts: remove orphaned ANT_ONLY_COMMAND_ALLOWLIST
   and unused GH_READ_ONLY_COMMANDS import
5. insights.ts: remove entire remote collection plumbing (types,
   functions, options, display logic)
6. osc.ts: hardcode supportsTabStatus() to false (internal-only feature)
7. state.ts: simplify addSlowOperation/getSlowOperations to no-ops,
   remove dead constants

* fix: address Copilot review on PR #644

1. settings/types.ts: allow 'max' effort level for all users in Zod
   schema — was still gated behind USER_TYPE=ant, causing 'max' to be
   silently dropped on settings reload
2. shouldUseSandbox.ts: defensively normalize disabledCommands from
   feature flag config with Array.isArray() guards

* fix: address second round of Copilot review on PR #644

1. shouldUseSandbox.ts: validate top-level shape of disabledCommands
   before accessing properties (handles null/primitive from feature flag)
2. fullscreen.ts: update JSDoc to reflect removal of USER_TYPE default
3. osc.ts: update JSDoc — "Ant-only" → "Currently disabled"
2026-04-14 19:08:54 +08:00
Fexiven
658d076909 feat: add Docker image build and push to GHCR on release (#656)
* feat: add Docker image build and push to GHCR on release

Add Dockerfile (multi-stage build with node:22-slim) and a new docker
job in the release workflow that builds and pushes to ghcr.io when
release-please creates a tag.

* feat(docker): run as non-root user and add smoke test

Run the container as a non-root appuser to reduce blast radius.
Add a smoke test step that runs --version before pushing to GHCR.
2026-04-14 19:03:10 +08:00
Vasanth T
a07e5ef990 fix: bump axios 1.14.0 → 1.15.0 (Dependabot #4, #5) (#670)
* fix: bump axios 1.14.0 → 1.15.0 (Dependabot #4, #5)

Resolve two critical Dependabot alerts:
- #5: Unrestricted Cloud Metadata Exfiltration via Header Injection Chain
- #4: NO_PROXY Hostname Normalization Bypass Leads to SSRF

Both require axios >= 1.15.0.

* fix: update bun.lock for axios 1.15.0

CI failed with 'lockfile had changes, but lockfile is frozen'.
Regenerated lockfile after axios bump.

---------

Co-authored-by: root <root@vm7508.lumadock.com>
2026-04-14 19:00:55 +08:00
FluxLuFFy
25ce2ca7bf fix: resolve 12 bugs across API, MCP, agent tools, web search, and context overflow (#674)
* fix: resolve 12 bugs across API, MCP, agent tools, web search, and context overflow

API fixes:
- Fix Gemini 400 error: delete 'store: false' field for Gemini endpoints
  (was globally injected, Gemini rejects unknown fields)
- Fix session timeout 500 errors after ~25min: add 120s idle timeout
  on SSE stream readers in openaiShim and codexShim to detect dead
  connections and trigger withRetry reconnection
- Fix context overflow 500 errors: add handler in errors.ts for 500
  responses caused by oversized conversation context (too many tokens),
  surfacing user-friendly message with recovery actions instead of raw
  'API Error: 500'

Agent loop fix:
- Fix premature task completion: detect continuation signals like
  'so now I have to do it' in assistant text without tool calls and
  inject a meta nudge to force the agent to continue

Web search improvements:
- Increase result counts: Bing/Tavily/Exa/Firecrawl from 10→15,
  Mojeek/You/Jina from default→10 (explicit), max_uses 8→15

MCP fixes:
- Reduce default tool timeout from ~27.8 hours to 5 minutes
  (tools no longer hang indefinitely on unresponsive servers)
- Add retry logic (3 attempts) for tools/list fetch failures
  (prevents all MCP tools from silently disappearing on timeout)
- Add abort signal check in URL elicitation retry loop
- Improve MCP error messages with server and tool name context

Agent tool fixes:
- Fix SendMessage race condition: double-check task status before
  auto-resuming stopped agents to prevent duplicate registration
- Fix auto-compact circuit breaker gap: when auto-compact fails 3+
  consecutive times, proactively block oversized context BEFORE the
  API call instead of letting it 500. Clear message with recovery
  instructions (/new, /compact, rewind).

Tests: 850 total, 0 failures (25 new bugfix tests)

* fix: address all 4 review blockers + 6 additional issues from PR #674

Blockers (from Vasanthdev2004 review):

1. Continuation nudge infinite loop — no loop guard
   Added continuationNudgeCount to State, capped at MAX_CONTINUATION_NUDGES (3).
   Counter increments on each nudge, resets on tool execution (next_turn).

2. Continuation signal regexes too broad — high false-positive rate
   Tightened all patterns to require explicit action verbs. Added completion
   marker check (done/finished/completed/summary). Broad patterns only fire
   on messages <80 chars.

3. BUGFIXES.md in repo root — scope contamination
   Removed. PR description already contains this info.

4. AgentTool dump state cleanup is comment-only, not a bug fix
   Wrapped clearInvokedSkillsForAgent and clearDumpState in individual
   try/catch blocks so one failure doesn't prevent the other.

Additional issues:

5+6. readWithTimeout ignores AbortSignal, timer leak on abort
   Added optional signal param to openaiStreamToAnthropic,
   codexStreamToAnthropic, collectCodexCompletedResponse, readSseEvents.
   Added abort listener that clears idle timer so AbortError surfaces
   cleanly instead of spurious idle timeout.

7. MCP error format change breaks consumers
   Reverted human-readable message to original errorDetails format.
   Moved server/tool context to telemetryMessage param only.

10. AgentTool test broken by comment change
   Updated test assertions to match new defensive cleanup text + try/catch.

12. Mojeek test regex dangerously broad
   Tightened to match searchParams.set('t', '10') specifically.

14. linkup.ts in providerCounts test — no result count field
   Removed from providers list (uses depth param, not result count).

15. Error message overlap between errors.ts and query.ts
   Prefixed errorDetails with 'Context overflow (500):' to distinguish.

Tests: 851 pass, 0 fail

---------

Co-authored-by: openclaude-bot <bot@openclaude.ai>
Co-authored-by: Fix Bot <fix@openclaude.dev>
2026-04-14 18:59:53 +08:00
Kevin Codex
1741f32cb7 docs: add GitLawb mirror to README (#669) 2026-04-13 22:53:56 +08:00
Henrique Fernandes
fc7dc9ca0d Add Codex OAuth provider flow for ChatGPT account sign-in (#503)
* feat: add Codex OAuth provider flow

* fix: harden Codex OAuth storage, session activation, and UI
2026-04-13 22:34:16 +08:00
Nourrisse Florian
252808bbd0 feat: activate message actions in open build (#632)
Enable the MESSAGE_ACTIONS feature flag so open-build users get the
shift+up keybinding for the message actions panel.

Gate sites: src/keybindings/defaultBindings.ts, src/screens/REPL.tsx
(5 total). Pure UI/keybinding feature with zero external dependencies.
2026-04-13 21:48:29 +08:00
Nourrisse Florian
0e48884f56 feat: local feature flag overrides via ~/.claude/feature-flags.json (#639)
* feat: local feature flag overrides via ~/.claude/feature-flags.json

Replace the GrowthBook no-op stub with a local JSON file reader that
gives open-build users control over ~50 tengu_* feature flags without
needing Anthropic's GrowthBook server.

How it works:
- On first flag lookup, lazily reads ~/.claude/feature-flags.json
- Returns the configured value if the key exists, defaultValue otherwise
- When the file is absent, behavior is identical to the current stub
- CLAUDE_FEATURE_FLAGS_FILE env var overrides the file path (CI/testing)

Example ~/.claude/feature-flags.json:
  { "tengu_kairos_cron": true, "tengu_scratch": true }

Continues the infrastructure work from #315 and #352. This is a
prerequisite for replacing remaining USER_TYPE gates with local config.

* fix: use ESM imports and validate JSON shape in growthbook stub

- Replace require('fs'/'path'/'os') with ESM imports (node: prefix)
  to avoid ReferenceError in ESM bundle output
- Validate JSON.parse result is a plain object before using `in` operator
  to prevent TypeError on non-object JSON values

Addresses Copilot review comments on #639

* fix: reset flags cache in resetGrowthBook and refreshGrowthBookFeatures

Set _flags back to undefined so subsequent lookups re-read the JSON
file. Enables runtime reload and proper test isolation.

Addresses Copilot review comment on #639

* docs: explain why checkSecurityRestrictionGate is excluded from local flags

This is a remote killswitch for bypassPermissions mode — exposing it
via the local JSON file would let users accidentally disable
--dangerously-skip-permissions without understanding why.

* test: add unit tests for growthbook stub local feature flags

Covers: valid JSON loading, missing file fallback, malformed JSON,
non-object JSON (primitive, array), cache invalidation via
resetGrowthBook/refreshGrowthBookFeatures, all getter variants,
and checkSecurityRestrictionGate always returning false.

12 tests, 21 assertions.

* fix: use Object.hasOwn instead of in operator for flag lookup

Prevents inherited prototype properties (toString, constructor, etc.)
from being returned as flag values.

Addresses Copilot review comment on #639

* fix: align gate stub signatures and add Boolean coercion

Address remaining Copilot review feedback:
- checkSecurityRestrictionGate: accept gate param to match real signature
- checkStatsigFeatureGate/checkGate: coerce with Boolean() like real impl
2026-04-13 21:40:33 +08:00
Nourrisse Florian
b818dd5958 feat: implement Monitor tool for streaming shell output (#649)
* feat: implement Monitor tool for streaming shell output

Add the Monitor tool that executes shell commands in the background and
streams stdout line-by-line as notifications to the model. This enables
real-time monitoring of logs, builds, and long-running processes.

Implementation:
- MonitorTool (src/tools/MonitorTool/) — spawns LocalShellTask with
  kind='monitor', returns immediately with task ID
- MonitorMcpTask (src/tasks/MonitorMcpTask/) — task lifecycle management
  and agent cleanup via killMonitorMcpTasksForAgent()
- MonitorPermissionRequest — permission dialog component

The codebase already had all integration points wired (tools.ts, tasks.ts,
PermissionRequest.tsx, LocalShellTask kind='monitor', BashTool prompt).
This PR provides the missing implementations.

* fix: command-specific permission rule + architecture docs

- MonitorPermissionRequest: "don't ask again" now creates a
  command-prefix rule (like BashTool) instead of a blanket
  tool-name-only rule that would auto-allow all Monitor commands
- MonitorMcpTask: clarify architecture comments explaining why
  monitor_mcp type exists as a registry stub while actual tasks
  are local_bash with kind='monitor'

* fix: address Copilot review feedback

- Fix permission rule field: expression → ruleContent (Copilot #1)
- Handle empty command prefix: skip rule creation (Copilot #2)
- Remove unused useTheme() import (Copilot #3)
- Save permission rules under 'Bash' toolName so bashToolHasPermission
  can match them — Monitor delegates to Bash permission system (Copilot #4)
- Remove unused logError import from MonitorMcpTask (Copilot #6)
- Copilot #5 (getAppState throws): same pattern as BashTool:915, not a bug
2026-04-13 21:39:07 +08:00
Nourrisse Florian
24d485f42f feat: activate local-only team memory in open build (#648)
* feat: activate local-only team memory in open build

Enable the TEAMMEM feature flag and the isTeamMemoryEnabled() gate so
team memory works in local-only mode for all open-build users.

Team memory is a shared memory system scoped per-project, stored at
~/.claude/projects/<project>/memory/team/. The implementation is
already almost entirely local — extraction, UI, prompts, file
detection, and path validation all work on local files.

The cloud sync overlay (OAuth + API) is cleanly separated: the
watcher does an early return when OAuth is unavailable, so the
feature degrades gracefully to local-only storage with no crashes.

What works locally:
- Memory extraction (auto + team, combined prompts)
- Team MEMORY.md loaded into conversation context
- File selector with team memory folder option
- Collapse tracking (read/search/write counts)
- Secret scanning before persistence
- Path validation + symlink protection

What requires OAuth (not available in open build):
- Cloud sync between team members
- Automatic push/pull via file watcher

* fix: preserve opt-out gate for team memory via feature flag

Change isTeamMemoryEnabled() to read tengu_herring_clock with default
true instead of unconditional return true. This enables team memory by
default while preserving user opt-out via ~/.claude/feature-flags.json.
2026-04-13 21:29:10 +08:00
Nourrisse Florian
99a17144ee feat: activate coordinator mode in open build (#647)
* feat: activate coordinator mode in open build

Enable the COORDINATOR_MODE feature flag and create the missing
src/coordinator/workerAgent.ts module that provides worker agent
definitions for the coordinator.

Coordinator mode is a multi-agent system where a coordinator agent
orchestrates independent workers via AgentTool, SendMessageTool,
and TaskStopTool. The implementation was already 99% complete
(19KB coordinatorMode.ts, 26 gate sites across 15 files) — only
the workerAgent module was missing from the source snapshot.

Workers get the standard built-in agents (general-purpose, explore,
plan). The coordinator system prompt (252 lines) handles all
orchestration logic.

Activate at runtime: CLAUDE_CODE_COORDINATOR_MODE=1
Optional scratchpad: set {"tengu_scratch": true} in
~/.claude/feature-flags.json (#639)

* fix: add worker agent type for coordinator mode

The coordinator system prompt instructs the model to spawn workers with
subagent_type: "worker", but no agent had agentType === 'worker'.
This caused AgentTool to throw "Agent type 'worker' not found" on
every coordinator spawn attempt.

Add a WORKER_AGENT definition that spreads GENERAL_PURPOSE_AGENT with
agentType: 'worker'. Also use the narrower BuiltInAgentDefinition type.

* feat: activate built-in explore and plan agents in open build

Enable BUILTIN_EXPLORE_PLAN_AGENTS so Explore (fast, haiku, read-only)
and Plan (architect, read-only) agents are available to all users in
both normal and coordinator modes.

This resolves the inconsistency flagged in code review: coordinator
workers had access to Explore/Plan agents while normal sessions did not.

The GrowthBook A/B test gate (tengu_amber_stoat) defaults to true via
the no-telemetry stub. Users can disable via feature-flags.json (#639).
2026-04-13 21:19:57 +08:00
muhnehh
df2b9f2b7b fix: improve fetch diagnostics for bootstrap and session requests (#646)
* fix: improve fetch diagnostics for bootstrap and session requests

* chore: derive session timeout from shared constant
2026-04-13 21:17:12 +08:00
Nourrisse Florian
adbe391e63 fix: replace broken bun:bundle shim with source pre-processing (#657)
* fix: replace broken bun:bundle shim with source pre-processing

The `onResolve`/`onLoad` plugin shim for `bun:bundle` was silently
ineffective in Bun v1.3.9+ — the `bun:` namespace is resolved by
Bun's native C++ resolver before the JS plugin phase runs. This meant
ALL `feature()` flags evaluated to `false` regardless of the
`featureFlags` map in build.ts (including `MONITOR_TOOL: true`).

Replace the shim with a source pre-processing step that:
1. Strips `import { feature } from 'bun:bundle'` from .ts/.tsx files
2. Replaces `feature('FLAG')` calls with boolean literals
3. Restores original files in a `finally` block after Bun.build()

Also extend the missing-module scanner to detect `require()` and
dynamic `import()` calls — not just static `import ... from` — since
modules behind feature() gates become resolvable when flags are enabled.

* fix: ensure source files are always restored after build

- Add SIGINT/SIGTERM handlers to restore pre-processed source files
  on abrupt termination (Ctrl+C, kill)
- Replace process.exit(1) with process.exitCode = 1 so the finally
  block runs on build failure
2026-04-13 21:07:08 +08:00
emsanakhchivan
03e0b06e07 fix: extend provider guard to protect anthropic profiles from cross-terminal override (#641)
The provider profile activation guard in applyActiveProviderProfileFromConfig()
only checked CLAUDE_CODE_USE_* environment flags, which are never set for the
default anthropic provider. This allowed two terminals sharing ~/.claude.json
to overwrite each other's active provider when one was using anthropic and
the other a third-party provider.

Now also checks the OCODE_PROVIDER_PROFILE_APPLIED flag, which is set by all
profiles including anthropic, preventing cross-terminal interference.

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-04-13 20:22:50 +08:00
Nourrisse Florian
31be66d764 feat: add allowBypassPermissionsMode setting (#658)
* feat: add allowBypassPermissionsMode setting

Allow bypass permissions mode to appear in the mode list via
settings.json without requiring the --allow-dangerously-skip-permissions
CLI flag. The disableBypassPermissionsMode setting retains priority.

* fix: address Copilot review feedback on allowBypassPermissionsMode

- Security: read allowBypassPermissionsMode only from trusted settings
  sources (user/local/flag/policy), excluding projectSettings to prevent
  a malicious repo from enabling bypass mode
- UX: update error messages to reference the correct CLI flag
  (--allow-dangerously-skip-permissions) and the new settings option
- Tests: add schema validation tests for the new field
2026-04-13 20:05:21 +08:00
Meetpatel006
7c8bdcc3e2 fix: route OpenAI Codex shortcuts to correct endpoint (#566)
* feat: enhance codex provider resolution with shortcut aliases and improved base URL handling

* fix: enhance codex alias resolution to include shell model

* feat: enhance Codex provider resolution to support new aliases and base URL handling

* fix: update base URL resolution logic for Codex models in GitHub mode

* fix: update provider transport logic to enforce Codex responses and adjust base URL handling

* fix: update provider request resolution to respect custom base URLs and adjust transport logic

* fix: restore OPENAI_MODEL environment variable handling in tests and provider config
2026-04-13 18:31:15 +08:00
Khaled Moayad
64298a663f feat: implement /loop command with fixed and dynamic scheduling (#621)
* feat: implement /loop command with fixed and dynamic scheduling modes

Enable cron tools and /loop skill without the AGENT_TRIGGERS build flag
by removing feature guards from tools.ts, REPL.tsx, and skill registration.
The isKairosCronEnabled() runtime gate now enables cron unconditionally for
open builds while preserving the GrowthBook kill switch for ant builds.

The /loop skill supports four modes: fixed-interval with prompt, fixed-interval
maintenance, dynamic-prompt (self-pacing), and dynamic maintenance (bare /loop).

* chore: remove unused DEFAULT_INTERVAL constant from loop skill

* revert: drop infra changes, scope PR to /loop skill rewrite only

The cron activation layer (AGENT_TRIGGERS guard removal, isKairosCronEnabled
hardcode) is covered by an in-flight stack (#633, #639). Scope this PR to
just the loop.ts rewrite and its tests so it can land cleanly on top.

* fix: restore infra changes needed for /loop in open build

Bun's constant folder evaluates feature('AGENT_TRIGGERS') at bundle time
through the bun:bundle shim — even when the flag is flipped to true in
build.ts, the folded value is cached from the previous build and stays false.
This means the feature-gated require() blocks for cron tools, useScheduledTasks,
and loop skill registration all compile to dead code regardless of the flag.

Fix by removing the AGENT_TRIGGERS guards from the specific paths /loop needs:
- tools.ts: cron tools always registered (isEnabled gates visibility)
- REPL.tsx: useScheduledTasks always mounted
- index.ts: registerLoopSkill via static import, called unconditionally
- prompt.ts: isKairosCronEnabled() bypasses feature flag for non-ant builds

* fix: replace backslash line continuations with explicit delimiters in loop prompts

The backslash-newline sequences inside template literals were acting as
line continuations, collapsing newlines and merging prompt content with
surrounding instruction text. Replace with --- BEGIN/END --- markers
for unambiguous delimiting.

Also add tests for trailing "every" clause parsing, human-readable unit
normalization, and the non-interval "check every PR" case.

* fix: remove remaining AGENT_TRIGGERS guards from print.ts and constants/tools.ts

Completes the cron guard removal started in the previous commit.
The cron scheduler in non-interactive (-p) mode was dead because
print.ts still gated cronSchedulerModule/cronGate requires behind
feature('AGENT_TRIGGERS'), which Bun constant-folds to false in open
builds. Similarly, cron tool names were absent from
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.

Remove all three guards so the scheduler initialises (gated at runtime
by isKairosCronEnabled) and cron tools are allowed for in-process
teammates in all builds.
2026-04-13 18:28:42 +08:00
Juan Camilo Auriti
30c866d31a fix(openai-shim): preserve tool result images and local token caps (#659)
Keep tool-result images as real image_url parts for OpenAI-compatible requests and use max_tokens for local providers like Ollama and LM Studio.
2026-04-13 18:20:05 +08:00
github-actions[bot]
f6a4455ecf chore(main): release 0.2.3 (#638)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 02:06:34 +08:00
Vasanth T
aeaa658f77 fix: prevent infinite auto-compact loop for unknown 3P models (#635) (#636)
- Raise context window fallback from 8k to 128k for unknown OpenAI-compat models.
  The 8k fallback caused effective context (8k minus output reservation) to go
  negative, making auto-compact fire on every single message.
- Add safety floor in getEffectiveContextWindowSize(): effective context is
  always at least reservedTokensForSummary + 13k buffer, ensuring the
  auto-compact threshold stays positive.
- Add missing MiniMax model entries (M2.5, M2.5-highspeed, M2.1, M2.1-highspeed)
  all at 204,800 context / 131,072 max output per MiniMax docs.
- Add tests for MiniMax variants, 128k fallback, and autoCompact floor.

Fixes #635

Co-authored-by: root <root@vm7508.lumadock.com>
2026-04-13 02:03:02 +08:00
github-actions[bot]
d2a057c6f1 chore(main): release 0.2.2 (#631)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 01:03:57 +08:00
Jeevan Mohan Pawar
08cc6f3287 fix(read/edit): make compact line prefix unambiguous for tab-indented files (#613) 2026-04-13 01:00:33 +08:00
Kevin Codex
84fcc7f7e0 ci: publish npm in release workflow (#630) 2026-04-13 01:00:07 +08:00
github-actions[bot]
ad11414def chore(main): release 0.2.1 (#629)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 00:41:31 +08:00
Jeevan Mohan Pawar
9419e8a4a2 fix(provider): add recovery guidance for missing OpenAI API key (#616) 2026-04-13 00:37:04 +08:00
Kevin Codex
41a86d05fa ci: publish from release events (#628) 2026-04-13 00:33:43 +08:00
Kevin Codex
fa4b6a96c0 Fix/manual publish current release (#627)
* ci: keep manual publish path for current release

* ci: fix trusted publishing metadata
2026-04-13 00:23:00 +08:00
Kevin Codex
d03d77b110 ci: keep manual publish path for current release (#626) 2026-04-13 00:18:43 +08:00
Kevin Codex
15de1d6190 Fix/release please invalid input (#624)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics

* ci: allow manual publish of existing release tags
2026-04-12 23:59:19 +08:00
Kevin Codex
812facf024 Fix/release please invalid input (#622)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics
2026-04-12 23:33:22 +08:00
Kevin Codex
2e39d2607a Fix/release please invalid input (#620)
* ci: remove invalid release-please input

* ci: add npm publish debug diagnostics
2026-04-12 23:24:39 +08:00
github-actions[bot]
a3633ac094 chore(main): release 0.2.0 (#617)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-12 22:43:06 +08:00
Kevin Codex
3cefe2297d ci: remove invalid release-please input (#618) 2026-04-12 22:40:38 +08:00
Kevin Codex
40ac164501 ci: add secure automated release workflow (#615)
* ci: add secure automated release workflow

* ci: fix release-please action pin
2026-04-12 21:57:00 +08:00
ZhaoXiaoLuo
b3f3dc4e66 Prefer AGENTS.md over CLAUDE.md for project instructions (#439)
* Prefer AGENTS.md over CLAUDE.md for project instructions

* fix: preserve CLAUDE.md fallback behavior

* fix: isolate onboarding tests and preserve legacy init

* fix: restore full fsOperations exports in test mock and align compact cwd

* Fix onboarding test isolation and init migration guidance

* Tighten init prompt coverage and onboarding copy

* Handle nested project instruction paths consistently

* Fix NEW_INIT feature gate for Bun build

---------

Co-authored-by: 赵小落 <zhaoxiaoluo@zhaoxiaoluodeMac-mini.local>
Co-authored-by: zhaomo01 <zhaomo01@baidu.com>
2026-04-12 21:31:33 +08:00
Nourrisse Florian
2e0e14d713 fix: add LiteLLM-style aliases for GitHub Copilot context windows (#606)
The OPENAI_CONTEXT_WINDOWS/OPENAI_MAX_OUTPUT_TOKENS tables only contained
the `github:copilot:<model>` namespaced form used when talking directly to
Copilot via /onboard-github. When OpenClaude is pointed at a LiteLLM proxy
(which routes Copilot using the standard `github_copilot/<model>` convention),
the lookup missed and fell back to the conservative 8k default — causing the
compaction loop to fire repeatedly on every tick and blocking requests
before they left the client with repeated "not in context window table"
warnings on stderr.

Mirror the 11 active Copilot models with LiteLLM-style keys in both tables.
No behavior change for users of /onboard-github since namespaced entries
remain untouched and `lookupByKey` picks exact matches first.
2026-04-12 21:10:17 +08:00
euxaristia
a02c44143b fix(web-search): close SSRF bypasses in custom provider hostname guard (#610)
The previous `isPrivateHostname` used a list of regexes against
`URL.hostname`. Several literal-address forms slipped past it:

- IPv4-mapped IPv6 `[::ffff:127.0.0.1]` (WHATWG URL normalizes to
  `[::ffff:7f00:1]`, which no regex matched) — lets callers reach
  loopback and other private v4 via an IPv6 literal.
- ULA `fc00::/7` (e.g. `[fc00::1]`) — not covered.
- Link-local `fe80::/10` (e.g. `[fe80::1]`) — not covered.
- IPv4 `169.254.0.0/16` (cloud metadata, including 169.254.169.254),
  `100.64.0.0/10` (CGNAT), and the full `0.0.0.0/8` — not covered.
- The IPv6 regex `/^\[::1?\]$/` also required brackets, but `URL.hostname`
  returns bracketed form anyway, so this part happened to work.

WHATWG `new URL(...)` already normalizes short-form / numeric / hex /
octal IPv4 to dotted-quad before we see it, so those cases were in fact
handled — the remaining gaps were IPv6 and a few missing v4 ranges.

Replace the regex list with:
- a dotted-quad IPv4 parser + int range check covering 0/8, 10/8,
  100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16;
- a small IPv6 parser (handles `::` compression and embedded v4 suffix)
  + a byte-range check covering `::`, `::1`, IPv4-mapped (recursing
  into the v4 classifier), IPv4-compatible, `fc00::/7`, `fe80::/10`,
  and `fec0::/10`.

Export `isPrivateHostname` and add unit tests covering every bypass
listed above plus public-address negatives.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:09:46 +08:00
euxaristia
7817fe88bd fix(web-search): stop leaking abort listeners in custom provider retry (#611)
`fetchWithRetry` created a fresh `AbortController` per attempt and did:

    signal?.addEventListener('abort', () => controller.abort(), { once: true })

The listener was never removed. Consequences:

- On retry, a second listener was attached to the caller's signal,
  each closing over a different controller.
- After a successful fetch, the listener remained on the caller's
  signal indefinitely, referencing a controller whose work was done.
  For a long-lived caller signal this is a slow leak.
- The `{ once: true }` only helps if the signal actually fires — on
  non-aborted signals the listener stays attached forever.

Replace the manual controller + timer + listener dance with
`AbortSignal.any([signal, AbortSignal.timeout(ms)])`, which the
codebase already uses elsewhere (see src/services/mcp/xaa.ts). This:

- has no user-code listener to leak,
- gives each attempt a fresh independent timeout,
- cleanly distinguishes caller-initiated abort from timeout via
  `signal.aborted` vs `timeoutSignal.aborted` before rewriting the
  error as "Custom search timed out after Ns".

Also resets `lastStatus` per attempt so a 5xx on attempt 0 can't leak
into attempt 1's retry decision, and collapses the two redundant
retry branches (`lastStatus >= 500` and `lastStatus === undefined`)
into one.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 19:37:08 +08:00
230 changed files with 18929 additions and 2514 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
coverage
reports
vscode-extension
python
docs
*.md
!README.md
.github
.tsbuildinfo

View File

@@ -225,6 +225,30 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# GOOGLE_CLOUD_PROJECT=your-gcp-project-id # GOOGLE_CLOUD_PROJECT=your-gcp-project-id
# -----------------------------------------------------------------------------
# Option 9: NVIDIA NIM
# -----------------------------------------------------------------------------
# NVIDIA NIM provides hosted inference endpoints for NVIDIA models.
# Get your API key from https://build.nvidia.com/
#
# CLAUDE_CODE_USE_OPENAI=1
# NVIDIA_API_KEY=nvapi-your-key-here
# OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
# OPENAI_MODEL=nvidia/llama-3.1-nemotron-70b-instruct
# -----------------------------------------------------------------------------
# Option 10: MiniMax
# -----------------------------------------------------------------------------
# MiniMax API provides text generation models.
# Get your API key from https://platform.minimax.io/
#
# CLAUDE_CODE_USE_OPENAI=1
# MINIMAX_API_KEY=your-minimax-key-here
# OPENAI_BASE_URL=https://api.minimax.io/v1
# OPENAI_MODEL=MiniMax-M2.5
# ============================================================================= # =============================================================================
# OPTIONAL TUNING # OPTIONAL TUNING
# ============================================================================= # =============================================================================

144
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,144 @@
name: Auto Release
on:
push:
branches:
- main
concurrency:
group: auto-release-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
if: ${{ github.repository == 'Gitlawb/openclaude' }}
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: node
publish-npm:
name: Publish to npm
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ needs.release-please.outputs.tag_name }}
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run unit tests
run: bun test --max-concurrency=1
- name: Smoke test
run: bun run smoke
- name: Build
run: bun run build
- name: Dry-run package
run: npm pack --dry-run
- name: Clear token auth for trusted publishing
run: |
unset NODE_AUTH_TOKEN
echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV"
- name: Publish to npm
run: npm publish --access public --provenance
- name: Release summary
run: |
{
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo
echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude"
echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY"
docker:
name: Build & Push Docker Image
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout release tag
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
type=raw,value=latest
- name: Build and load locally
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
load: true
tags: openclaude:smoke
cache-from: type=gha
- name: Smoke test
run: docker run --rm openclaude:smoke --version
- name: Build and push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -0,0 +1,3 @@
{
".": "0.5.2"
}

148
CHANGELOG.md Normal file
View File

@@ -0,0 +1,148 @@
# Changelog
## [0.5.2](https://github.com/Gitlawb/openclaude/compare/v0.5.1...v0.5.2) (2026-04-20)
### Bug Fixes
* **api:** replace phrase-based reasoning sanitizer with tag-based filter ([#779](https://github.com/Gitlawb/openclaude/issues/779)) ([336ddcc](https://github.com/Gitlawb/openclaude/commit/336ddcc50d59d79ebff50993f2673652aecb0d7d))
## [0.5.1](https://github.com/Gitlawb/openclaude/compare/v0.5.0...v0.5.1) (2026-04-20)
### Bug Fixes
* enforce Bash path constraints after sandbox allow ([#777](https://github.com/Gitlawb/openclaude/issues/777)) ([7002cb3](https://github.com/Gitlawb/openclaude/commit/7002cb302b78ea2a19da3f26226de24e2903fa1d))
* enforce MCP OAuth callback state before errors ([#775](https://github.com/Gitlawb/openclaude/issues/775)) ([739b8d1](https://github.com/Gitlawb/openclaude/commit/739b8d1f40fde0e401a5cbd2b9a55d88bd5124ad))
* require trusted approval for sandbox override ([#778](https://github.com/Gitlawb/openclaude/issues/778)) ([aab4890](https://github.com/Gitlawb/openclaude/commit/aab489055c53dd64369414116fe93226d2656273))
## [0.5.0](https://github.com/Gitlawb/openclaude/compare/v0.4.0...v0.5.0) (2026-04-20)
### Features
* add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization ([#770](https://github.com/Gitlawb/openclaude/issues/770)) ([e6e8d9a](https://github.com/Gitlawb/openclaude/commit/e6e8d9a24897e4c9ef08b72df20fabbf8ef27f38))
* mask provider api key input ([#772](https://github.com/Gitlawb/openclaude/issues/772)) ([13e9f22](https://github.com/Gitlawb/openclaude/commit/13e9f22a83a2b0f85f557b1e12c9442ba61241e4))
### Bug Fixes
* allow provider recovery during startup ([#765](https://github.com/Gitlawb/openclaude/issues/765)) ([f828171](https://github.com/Gitlawb/openclaude/commit/f828171ef1ab94e2acf73a28a292799e4e26cc0d))
* **api:** drop orphan tool results to satisfy strict role sequence ([#745](https://github.com/Gitlawb/openclaude/issues/745)) ([b786b76](https://github.com/Gitlawb/openclaude/commit/b786b765f01f392652eaf28ed3579a96b7260a53))
* **help:** prevent /help tab crash from undefined descriptions ([#732](https://github.com/Gitlawb/openclaude/issues/732)) ([3d1979f](https://github.com/Gitlawb/openclaude/commit/3d1979ff066db32415e0c8321af916d81f5f2621))
* **mcp:** sync required array with properties in tool schemas ([#754](https://github.com/Gitlawb/openclaude/issues/754)) ([002a8f1](https://github.com/Gitlawb/openclaude/commit/002a8f1f6de2fcfc917165d828501d3047bad61f))
* remove cached mcpClient in diagnostic tracking to prevent stale references ([#727](https://github.com/Gitlawb/openclaude/issues/727)) ([2c98be7](https://github.com/Gitlawb/openclaude/commit/2c98be700274a4241963b5f43530bf3bd8f8963f))
* use raw context window for auto-compact percentage display ([#748](https://github.com/Gitlawb/openclaude/issues/748)) ([55c5f26](https://github.com/Gitlawb/openclaude/commit/55c5f262a9a5a8be0aa9ae8dc6c7dafc465eb2c6))
## [0.4.0](https://github.com/Gitlawb/openclaude/compare/v0.3.0...v0.4.0) (2026-04-17)
### Features
* add Alibaba Coding Plan (DashScope) provider support ([#509](https://github.com/Gitlawb/openclaude/issues/509)) ([43ac6db](https://github.com/Gitlawb/openclaude/commit/43ac6dba75537282da1e2ad8f855082bc4e25f1e))
* add NVIDIA NIM and MiniMax provider support ([#552](https://github.com/Gitlawb/openclaude/issues/552)) ([51191d6](https://github.com/Gitlawb/openclaude/commit/51191d61326e1f8319d70b3a3c0d9229e185a564))
* add ripgrep to Dockerfile for faster file searching ([#688](https://github.com/Gitlawb/openclaude/issues/688)) ([12dd375](https://github.com/Gitlawb/openclaude/commit/12dd3755c619cc27af3b151ae8fdb9d425a7b9a2))
* **api:** classify openai-compatible provider failures ([#708](https://github.com/Gitlawb/openclaude/issues/708)) ([80a00ac](https://github.com/Gitlawb/openclaude/commit/80a00acc2c6dc4657a78de7366f7a9ebc920bfbb))
* **vscode:** add full chat interface to OpenClaude extension ([#608](https://github.com/Gitlawb/openclaude/issues/608)) ([fbcd928](https://github.com/Gitlawb/openclaude/commit/fbcd928f7f8511da795aea3ad318bddf0ab9a1a7))
### Bug Fixes
* focus "Done" option after completing provider manager actions ([#718](https://github.com/Gitlawb/openclaude/issues/718)) ([d6f5130](https://github.com/Gitlawb/openclaude/commit/d6f5130c204d8ffe582212466768706cd7fd6774))
* **models:** prevent /models crash from non-string saved model values ([#691](https://github.com/Gitlawb/openclaude/issues/691)) ([6b2121d](https://github.com/Gitlawb/openclaude/commit/6b2121da12189fa7ce1f33394d18abd24cf8a01b))
* prevent crash in commands tab when description is undefined ([#730](https://github.com/Gitlawb/openclaude/issues/730)) ([eed77e6](https://github.com/Gitlawb/openclaude/commit/eed77e6579866a98384dcc948a0ad6406614ede3))
* strip comments before scanning for missing imports ([#676](https://github.com/Gitlawb/openclaude/issues/676)) ([a00b792](https://github.com/Gitlawb/openclaude/commit/a00b7928de9662ffb7ef6abd8cd040afe6f4f122))
* **ui:** show correct endpoint URL in intro screen for custom Anthropic endpoints ([#735](https://github.com/Gitlawb/openclaude/issues/735)) ([3424663](https://github.com/Gitlawb/openclaude/commit/34246635fb9a09499047a52e7f96ca9b36c8a85a))
## [0.3.0](https://github.com/Gitlawb/openclaude/compare/v0.2.3...v0.3.0) (2026-04-14)
### Features
* activate coordinator mode in open build ([#647](https://github.com/Gitlawb/openclaude/issues/647)) ([99a1714](https://github.com/Gitlawb/openclaude/commit/99a17144ee285b892a0801acb6abcc9af68879af))
* activate local-only team memory in open build ([#648](https://github.com/Gitlawb/openclaude/issues/648)) ([24d485f](https://github.com/Gitlawb/openclaude/commit/24d485f42f5b1405d2fab13f2f497d5edd3b5300))
* activate message actions in open build ([#632](https://github.com/Gitlawb/openclaude/issues/632)) ([252808b](https://github.com/Gitlawb/openclaude/commit/252808bbd0a12a6ccf97e2cb09752a0212ea3acd))
* add allowBypassPermissionsMode setting ([#658](https://github.com/Gitlawb/openclaude/issues/658)) ([31be66d](https://github.com/Gitlawb/openclaude/commit/31be66d7645ea3473334c9ce89ea1a5095b8df6e))
* add Docker image build and push to GHCR on release ([#656](https://github.com/Gitlawb/openclaude/issues/656)) ([658d076](https://github.com/Gitlawb/openclaude/commit/658d076909e14eb0459bcb98aee9aa0472118265))
* implement /loop command with fixed and dynamic scheduling ([#621](https://github.com/Gitlawb/openclaude/issues/621)) ([64298a6](https://github.com/Gitlawb/openclaude/commit/64298a663f1391b16aa1f5a49e8a877e1d3742f2))
* implement Monitor tool for streaming shell output ([#649](https://github.com/Gitlawb/openclaude/issues/649)) ([b818dd5](https://github.com/Gitlawb/openclaude/commit/b818dd5958f4e8428566ce25a1a6be5fd4fe66f8))
* local feature flag overrides via ~/.claude/feature-flags.json ([#639](https://github.com/Gitlawb/openclaude/issues/639)) ([0e48884](https://github.com/Gitlawb/openclaude/commit/0e48884f56c6c008f047a7926d3b2cb924170625))
* open useful USER_TYPE-gated features to all users ([#644](https://github.com/Gitlawb/openclaude/issues/644)) ([c1beea9](https://github.com/Gitlawb/openclaude/commit/c1beea98676a413c54152a45a6b9fbe7fb9ed028))
### Bug Fixes
* bump axios 1.14.0 → 1.15.0 (Dependabot [#4](https://github.com/Gitlawb/openclaude/issues/4), [#5](https://github.com/Gitlawb/openclaude/issues/5)) ([#670](https://github.com/Gitlawb/openclaude/issues/670)) ([a07e5ef](https://github.com/Gitlawb/openclaude/commit/a07e5ef990a5ed01a72e83fdbd1fcab36f515a08))
* extend provider guard to protect anthropic profiles from cross-terminal override ([#641](https://github.com/Gitlawb/openclaude/issues/641)) ([03e0b06](https://github.com/Gitlawb/openclaude/commit/03e0b06e0784e4ea46945b3950840b10b6e3ca49))
* improve fetch diagnostics for bootstrap and session requests ([#646](https://github.com/Gitlawb/openclaude/issues/646)) ([df2b9f2](https://github.com/Gitlawb/openclaude/commit/df2b9f2b7b4c661ee3d9ed5dc58b3064de0599d1))
* **openai-shim:** preserve tool result images and local token caps ([#659](https://github.com/Gitlawb/openclaude/issues/659)) ([30c866d](https://github.com/Gitlawb/openclaude/commit/30c866d31ad8538496460667d86ed5efbd4a8547))
* replace broken bun:bundle shim with source pre-processing ([#657](https://github.com/Gitlawb/openclaude/issues/657)) ([adbe391](https://github.com/Gitlawb/openclaude/commit/adbe391e63721918b5d147f4f845111c1a3143db))
* resolve 12 bugs across API, MCP, agent tools, web search, and context overflow ([#674](https://github.com/Gitlawb/openclaude/issues/674)) ([25ce2ca](https://github.com/Gitlawb/openclaude/commit/25ce2ca7bff8937b0b79ad7f85c6dc1c68432069))
* route OpenAI Codex shortcuts to correct endpoint ([#566](https://github.com/Gitlawb/openclaude/issues/566)) ([7c8bdcc](https://github.com/Gitlawb/openclaude/commit/7c8bdcc3e2ac1ecb98286c705c85671044be3d6b))
## [0.2.3](https://github.com/Gitlawb/openclaude/compare/v0.2.2...v0.2.3) (2026-04-12)
### Bug Fixes
* prevent infinite auto-compact loop for unknown 3P models ([#635](https://github.com/Gitlawb/openclaude/issues/635)) ([#636](https://github.com/Gitlawb/openclaude/issues/636)) ([aeaa658](https://github.com/Gitlawb/openclaude/commit/aeaa658f776fb8df95721e8b8962385f8b00f66a))
## [0.2.2](https://github.com/Gitlawb/openclaude/compare/v0.2.1...v0.2.2) (2026-04-12)
### Bug Fixes
* **read/edit:** make compact line prefix unambiguous for tab-indented files ([#613](https://github.com/Gitlawb/openclaude/issues/613)) ([08cc6f3](https://github.com/Gitlawb/openclaude/commit/08cc6f328711cd93ce9fa53351266c29a0b0a341))
## [0.2.1](https://github.com/Gitlawb/openclaude/compare/v0.2.0...v0.2.1) (2026-04-12)
### Bug Fixes
* **provider:** add recovery guidance for missing OpenAI API key ([#616](https://github.com/Gitlawb/openclaude/issues/616)) ([9419e8a](https://github.com/Gitlawb/openclaude/commit/9419e8a4a21b3771d9ddb10f7072e0a8c5b5b631))
## [0.2.0](https://github.com/Gitlawb/openclaude/compare/v0.1.8...v0.2.0) (2026-04-12)
### Features
* add /cache-probe diagnostic command ([#580](https://github.com/Gitlawb/openclaude/issues/580)) ([9ccaa7a](https://github.com/Gitlawb/openclaude/commit/9ccaa7a6759b6991f4a566b4118c06e68a2398fe)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add auto-fix service — auto-lint and test after AI file edits ([#508](https://github.com/Gitlawb/openclaude/issues/508)) ([c385047](https://github.com/Gitlawb/openclaude/commit/c385047abba4366866f4c87bfb5e0b0bd4dcbb9d))
* Add Gemini support with thought_signature fix ([#404](https://github.com/Gitlawb/openclaude/issues/404)) ([5012c16](https://github.com/Gitlawb/openclaude/commit/5012c160c9a2dff9418e7ee19dc9a4d29ef2b024))
* add headless gRPC server for external agent integration ([#278](https://github.com/Gitlawb/openclaude/issues/278)) ([26eef92](https://github.com/Gitlawb/openclaude/commit/26eef92fe72e9c3958d61435b8d3571e12bf2b74))
* add wiki mvp commands ([#532](https://github.com/Gitlawb/openclaude/issues/532)) ([c328fdf](https://github.com/Gitlawb/openclaude/commit/c328fdf9e2fe59ad101b049301298ce9ff24caca))
* GitHub provider lifecycle and onboarding hardening ([#351](https://github.com/Gitlawb/openclaude/issues/351)) ([ff7d499](https://github.com/Gitlawb/openclaude/commit/ff7d49990de515825ddbe4099f3a39b944b61370))
### Bug Fixes
* add File polyfill for Node &lt; 20 to prevent startup deadlock with proxy ([#442](https://github.com/Gitlawb/openclaude/issues/442)) ([85aa8b0](https://github.com/Gitlawb/openclaude/commit/85aa8b0985c8f3cb8801efa5141114a0ab0f6a83))
* add GitHub Copilot model context windows and output limits ([#576](https://github.com/Gitlawb/openclaude/issues/576)) ([a7f5982](https://github.com/Gitlawb/openclaude/commit/a7f5982f6438ab0ddc3f0daae31ea68ac7ac206c)), closes [#515](https://github.com/Gitlawb/openclaude/issues/515)
* add LiteLLM-style aliases for GitHub Copilot context windows ([#606](https://github.com/Gitlawb/openclaude/issues/606)) ([2e0e14d](https://github.com/Gitlawb/openclaude/commit/2e0e14d71313e0e501efaa9e55c6c56f2742fb10))
* add store:false to Chat Completions and /responses fallback ([#578](https://github.com/Gitlawb/openclaude/issues/578)) ([8aaa4f2](https://github.com/Gitlawb/openclaude/commit/8aaa4f22ac5b942d82aa9cad54af30d56034515a))
* address code scanning alerts ([#434](https://github.com/Gitlawb/openclaude/issues/434)) ([e365cb4](https://github.com/Gitlawb/openclaude/commit/e365cb4010becabacd7cbccb4c3e59ea23a41e90))
* avoid sync github credential reads in provider manager ([#428](https://github.com/Gitlawb/openclaude/issues/428)) ([aff2bd8](https://github.com/Gitlawb/openclaude/commit/aff2bd87e4f2821992f74fb95481c505d0ba5d5d))
* convert dragged file paths to [@mentions](https://github.com/mentions) for attachment ([#382](https://github.com/Gitlawb/openclaude/issues/382)) ([112df59](https://github.com/Gitlawb/openclaude/commit/112df5911791ea71ee9efbb98ea59c5ded1ea161))
* custom web search — WEB_URL_TEMPLATE not recognized, timeout too short, silent native fallback ([#537](https://github.com/Gitlawb/openclaude/issues/537)) ([32fbd0c](https://github.com/Gitlawb/openclaude/commit/32fbd0c7b4168b32dcb13a5b69342e2727269201))
* defer startup checks and suppress recommendation dialogs during startup window (issue [#363](https://github.com/Gitlawb/openclaude/issues/363)) ([#504](https://github.com/Gitlawb/openclaude/issues/504)) ([2caf2fd](https://github.com/Gitlawb/openclaude/commit/2caf2fd982af1ec845c50152ad9d28d1a597f82f))
* display selected model in startup screen instead of hardcoded sonnet 4.6 ([#587](https://github.com/Gitlawb/openclaude/issues/587)) ([b126e38](https://github.com/Gitlawb/openclaude/commit/b126e38b1affddd2de83fcc3ba26f2e44b42a509))
* handle missing skill parameter in SkillTool ([#485](https://github.com/Gitlawb/openclaude/issues/485)) ([f9ce81b](https://github.com/Gitlawb/openclaude/commit/f9ce81bfb384e909353813fb6f6760cadd508ae7))
* include MCP tool results in microcompact to reduce token waste ([#348](https://github.com/Gitlawb/openclaude/issues/348)) ([52d33a8](https://github.com/Gitlawb/openclaude/commit/52d33a87a047b943aedaaaf772cd48636c263509))
* **ink:** restore host prop updates in React 19 reconciler ([#589](https://github.com/Gitlawb/openclaude/issues/589)) ([6e94dd9](https://github.com/Gitlawb/openclaude/commit/6e94dd913688b2d6433a9abe62a245c5f031b776))
* let saved provider profiles win on restart ([#513](https://github.com/Gitlawb/openclaude/issues/513)) ([cb8f8b7](https://github.com/Gitlawb/openclaude/commit/cb8f8b7ac2e3e74516ee219a3a48156db7c6ed78))
* normalize malformed Bash tool arguments from OpenAI-compatible providers ([#385](https://github.com/Gitlawb/openclaude/issues/385)) ([b4bd95b](https://github.com/Gitlawb/openclaude/commit/b4bd95b47715c9896240d708c106777507fd26ec))
* preserve only originally-required properties in strict tool schemas ([#471](https://github.com/Gitlawb/openclaude/issues/471)) ([ccaa193](https://github.com/Gitlawb/openclaude/commit/ccaa193eec5761f0972ffb58eb3189a81a9244b0))
* preserve unicode in Windows clipboard fallback ([#388](https://github.com/Gitlawb/openclaude/issues/388)) ([c193497](https://github.com/Gitlawb/openclaude/commit/c1934974aaf64db460cc850a044bd13cc744cce7))
* rebrand prompt identity to openclaude ([#496](https://github.com/Gitlawb/openclaude/issues/496)) ([598651f](https://github.com/Gitlawb/openclaude/commit/598651f42389ce76311ec00e8a9c701c939ead27))
* replace isDeepStrictEqual with navigation-aware options comparison ([#507](https://github.com/Gitlawb/openclaude/issues/507)) ([537c469](https://github.com/Gitlawb/openclaude/commit/537c469c3a2f7cb0eed05fa2f54dca57b6bc273f)), closes [#472](https://github.com/Gitlawb/openclaude/issues/472)
* report cache reads in streaming and correct cost calculation ([#577](https://github.com/Gitlawb/openclaude/issues/577)) ([f4ac709](https://github.com/Gitlawb/openclaude/commit/f4ac709fa6eda732bf45204fcab625ba6c5674b9))
* restore default context window for unknown 3p models ([#494](https://github.com/Gitlawb/openclaude/issues/494)) ([69ea1f1](https://github.com/Gitlawb/openclaude/commit/69ea1f1e4a99e9436215d8cb391a116a64442b94))
* restore Grep and Glob reliability on OpenAI paths ([#461](https://github.com/Gitlawb/openclaude/issues/461)) ([600c01f](https://github.com/Gitlawb/openclaude/commit/600c01faf761a080a2c7dede872ddbe05a132f23))
* restore Ollama auto-detect in first-run setup ([#561](https://github.com/Gitlawb/openclaude/issues/561)) ([68c2968](https://github.com/Gitlawb/openclaude/commit/68c296833dcef54ce44cb18b24357230b5204dbc))
* scrub canonical Anthropic headers from 3P shim requests ([#499](https://github.com/Gitlawb/openclaude/issues/499)) ([07621a6](https://github.com/Gitlawb/openclaude/commit/07621a6f8d0918170281869a47b5dbff90e71594))
* strip Anthropic params from 3P resume paths ([#479](https://github.com/Gitlawb/openclaude/issues/479)) ([4975cfc](https://github.com/Gitlawb/openclaude/commit/4975cfc2e0ddbe34aa4e8e3f52ee5eba07fbe465))
* suppress startup dialogs when input is buffered ([#423](https://github.com/Gitlawb/openclaude/issues/423)) ([8ece290](https://github.com/Gitlawb/openclaude/commit/8ece2900872dadd157e798ef501ddf126dac66c4))
* **tui:** restore prompt rendering on startup ([#498](https://github.com/Gitlawb/openclaude/issues/498)) ([e30ad17](https://github.com/Gitlawb/openclaude/commit/e30ad17ae0056787273be2caafd6cf5340b6ab57))
* update theme preview on focus change ([#562](https://github.com/Gitlawb/openclaude/issues/562)) ([6924718](https://github.com/Gitlawb/openclaude/commit/692471850fc789ee0797190089272407f9a4d953))
* **web-search:** close SSRF bypasses in custom provider hostname guard ([#610](https://github.com/Gitlawb/openclaude/issues/610)) ([a02c441](https://github.com/Gitlawb/openclaude/commit/a02c44143b257fbee7f38f1b93873cc0ea68a1f9))
* WebSearch providers + MCPTool bugs ([#593](https://github.com/Gitlawb/openclaude/issues/593)) ([91e4cfb](https://github.com/Gitlawb/openclaude/commit/91e4cfb15b62c04615834fd3c417fe38b4feb914))

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# ---- build stage ----
FROM node:22-slim AS build
# Install Bun
RUN npm install -g bun@1.3.11
WORKDIR /app
# Copy dependency manifests first for better layer caching
COPY package.json bun.lock ./
# Install all dependencies (including devDependencies for build)
RUN bun install --frozen-lockfile
# Copy source code
COPY src/ src/
COPY scripts/ scripts/
COPY bin/ bin/
COPY tsconfig.json ./
# Build the CLI bundle
RUN bun run build
# Prune devDependencies
RUN rm -rf node_modules && bun install --frozen-lockfile --production
# ---- runtime stage ----
FROM node:22-slim
WORKDIR /app
# Copy only what's needed to run
COPY --from=build /app/dist/cli.mjs dist/cli.mjs
COPY --from=build /app/bin/ bin/
COPY --from=build /app/node_modules/ node_modules/
COPY --from=build /app/package.json package.json
COPY README.md ./
# Install git and ripgrep — many CLI tool operations depend on them
RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Run as non-root user
USER node
ENTRYPOINT ["node", "/app/dist/cli.mjs"]

View File

@@ -2,7 +2,7 @@
OpenClaude is an open-source coding-agent CLI for cloud and local model providers. OpenClaude is an open-source coding-agent CLI for cloud and local model providers.
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output. Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
[![PR Checks](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml/badge.svg?branch=main)](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml) [![PR Checks](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml/badge.svg?branch=main)](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
[![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags) [![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags)
@@ -10,13 +10,20 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
[![Security Policy](https://img.shields.io/badge/security-policy-0f766e)](SECURITY.md) [![Security Policy](https://img.shields.io/badge/security-policy-0f766e)](SECURITY.md)
[![License](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE)
OpenClaude is also mirrored to GitLawb:
[gitlawb.com/node/repos/z6MkqDnb/openclaude](https://gitlawb.com/node/repos/z6MkqDnb/openclaude)
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community) [Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=gitlawb/openclaude&type=date&legend=top-left)](https://www.star-history.com/?repos=gitlawb%2Fopenclaude&type=date&legend=top-left)
## Why OpenClaude ## Why OpenClaude
- Use one CLI across cloud APIs and local model backends - Use one CLI across cloud APIs and local model backends
- Save provider profiles inside the app with `/provider` - Save provider profiles inside the app with `/provider`
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported providers - Run with OpenAI-compatible services, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported providers
- Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools - Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
- Use the bundled VS Code extension for launch integration and theme support - Use the bundled VS Code extension for launch integration and theme support
@@ -85,6 +92,16 @@ $env:OPENAI_MODEL="qwen2.5-coder:7b"
openclaude openclaude
``` ```
### Using Ollama's launch command
If you have [Ollama](https://ollama.com) installed, you can skip the env var setup entirely:
```bash
ollama launch openclaude --model qwen2.5-coder:7b
```
This automatically sets `ANTHROPIC_BASE_URL`, model routing, and auth so all API traffic goes through your local Ollama instance. Works with any model you have pulled — local or cloud.
## Setup Guides ## Setup Guides
Beginner-friendly guides: Beginner-friendly guides:
@@ -105,8 +122,9 @@ Advanced and source-build guides:
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
| Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` | | Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` |
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials | | GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex | `/provider` | Uses existing Codex credentials when available | | Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
| Ollama | `/provider` or env vars | Local inference with no API key | | Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
| Ollama | `/provider`, env vars, or `ollama launch` | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend | | Atomic Chat | advanced setup | Local Apple Silicon backend |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments | | Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
@@ -313,7 +331,8 @@ For larger changes, open an issue first so the scope is clear before implementat
- `bun run build` - `bun run build`
- `bun run test:coverage` - `bun run test:coverage`
- `bun run smoke` - `bun run smoke`
- focused `bun test ...` runs for touched areas - focused `bun test ...` runs for files and flows you changed
## Disclaimer ## Disclaimer

View File

@@ -30,7 +30,7 @@
"@opentelemetry/semantic-conventions": "1.40.0", "@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0", "ajv": "8.18.0",
"auto-bind": "5.0.1", "auto-bind": "5.0.1",
"axios": "1.14.0", "axios": "1.15.0",
"bidi-js": "1.0.3", "bidi-js": "1.0.3",
"chalk": "5.6.2", "chalk": "5.6.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
@@ -479,7 +479,7 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
@@ -1151,6 +1151,8 @@
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@mendable/firecrawl-js/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="],
@@ -1377,6 +1379,8 @@
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"firecrawl/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],

View File

@@ -48,6 +48,8 @@ export OPENAI_MODEL=gpt-4o
`codexplan` maps to GPT-5.4 on the Codex backend with high reasoning. `codexplan` maps to GPT-5.4 on the Codex backend with high reasoning.
`codexspark` maps to GPT-5.3 Codex Spark for faster loops. `codexspark` maps to GPT-5.3 Codex Spark for faster loops.
If you use the in-app provider wizard, choose `Codex OAuth` to open ChatGPT sign-in in your browser and let OpenClaude store Codex credentials securely.
If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`. If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`.
```bash ```bash
@@ -82,6 +84,16 @@ OpenRouter model availability changes over time. If a model stops working, try a
### Ollama ### Ollama
Using `ollama launch` (recommended if you have Ollama installed):
```bash
ollama launch openclaude --model llama3.3:70b
```
This handles all environment setup automatically — no env vars needed. Works with any local or cloud model available in your Ollama instance.
Using environment variables manually:
```bash ```bash
ollama pull llama3.3:70b ollama pull llama3.3:70b

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gitlawb/openclaude", "name": "@gitlawb/openclaude",
"version": "0.1.8", "version": "0.5.2",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models", "description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -76,7 +76,7 @@
"@opentelemetry/semantic-conventions": "1.40.0", "@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0", "ajv": "8.18.0",
"auto-bind": "5.0.1", "auto-bind": "5.0.1",
"axios": "1.14.0", "axios": "1.15.0",
"bidi-js": "1.0.3", "bidi-js": "1.0.3",
"chalk": "5.6.2", "chalk": "5.6.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
@@ -140,7 +140,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://gitlawb.com/z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr/openclaude" "url": "https://github.com/Gitlawb/openclaude.git"
}, },
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"package-name": "@gitlawb/openclaude",
"bump-minor-pre-major": true,
"include-v-in-tag": true
}
}
}

View File

@@ -8,7 +8,8 @@
* - src/ path aliases * - src/ path aliases
*/ */
import { readFileSync } from 'fs' import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { noTelemetryPlugin } from './no-telemetry-plugin' import { noTelemetryPlugin } from './no-telemetry-plugin'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -24,25 +25,84 @@ const featureFlags: Record<string, boolean> = {
BRIDGE_MODE: false, BRIDGE_MODE: false,
DAEMON: false, DAEMON: false,
AGENT_TRIGGERS: false, AGENT_TRIGGERS: false,
MONITOR_TOOL: false, MONITOR_TOOL: true,
ABLATION_BASELINE: false, ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false, DUMP_SYSTEM_PROMPT: false,
CACHED_MICROCOMPACT: false, CACHED_MICROCOMPACT: false,
COORDINATOR_MODE: false, COORDINATOR_MODE: true,
BUILTIN_EXPLORE_PLAN_AGENTS: true,
CONTEXT_COLLAPSE: false, CONTEXT_COLLAPSE: false,
COMMIT_ATTRIBUTION: false, COMMIT_ATTRIBUTION: false,
TEAMMEM: false, TEAMMEM: true,
UDS_INBOX: false, UDS_INBOX: false,
BG_SESSIONS: false, BG_SESSIONS: false,
AWAY_SUMMARY: false, AWAY_SUMMARY: false,
TRANSCRIPT_CLASSIFIER: false, TRANSCRIPT_CLASSIFIER: false,
WEB_BROWSER_TOOL: false, WEB_BROWSER_TOOL: false,
MESSAGE_ACTIONS: false, MESSAGE_ACTIONS: true,
BUDDY: true, BUDDY: true,
CHICAGO_MCP: false, CHICAGO_MCP: false,
COWORKER_TYPE_TELEMETRY: false, COWORKER_TYPE_TELEMETRY: false,
} }
// ── Pre-process: replace feature() calls with boolean literals ──────
// Bun v1.3.9+ resolves `import { feature } from 'bun:bundle'` natively
// before plugins can intercept it via onResolve. The bun: namespace is
// handled by Bun's C++ resolver which runs before the JS plugin phase,
// so the previous onResolve/onLoad shim was silently ineffective — ALL
// feature() calls evaluated to false regardless of the featureFlags map.
//
// Fix: pre-process source files to strip the bun:bundle import and
// replace feature('FLAG') calls with their boolean literal. Files are
// modified in-place before Bun.build() and restored in a finally block.
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
const modifiedFiles = new Map<string, string>() // path → original content
function preProcessFeatureFlags(dir: string) {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, ent.name)
if (ent.isDirectory()) { preProcessFeatureFlags(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const raw = readFileSync(full, 'utf-8')
if (!raw.includes('feature(')) continue
let contents = raw
contents = contents.replace(featureImportRe, '')
contents = contents.replace(featureCallRe, (_match, name) =>
String((featureFlags as Record<string, boolean>)[name] ?? false),
)
if (contents !== raw) {
modifiedFiles.set(full, raw)
writeFileSync(full, contents)
}
}
}
function restoreModifiedFiles() {
for (const [path, original] of modifiedFiles) {
writeFileSync(path, original)
}
modifiedFiles.clear()
}
preProcessFeatureFlags(join(import.meta.dir, '..', 'src'))
const numModified = modifiedFiles.size
// Restore source files on abrupt termination (Ctrl+C, kill, etc.)
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
restoreModifiedFiles()
process.exit(signal === 'SIGINT' ? 130 : 143)
})
}
try {
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ['./src/entrypoints/cli.tsx'], entrypoints: ['./src/entrypoints/cli.tsx'],
outdir: './dist', outdir: './dist',
@@ -103,18 +163,11 @@ export async function handleBgFlag() { throw new Error("Background sessions are
], ],
] as const) ] as const)
// Resolve `import { feature } from 'bun:bundle'` to a shim // bun:bundle feature() replacement is handled by the source
build.onResolve({ filter: /^bun:bundle$/ }, () => ({ // pre-processing step above (see preProcessFeatureFlags).
path: 'bun:bundle', // The previous onResolve/onLoad shim was ineffective in Bun
namespace: 'bun-bundle-shim', // v1.3.9+ because the bun: namespace is resolved natively
})) // before the JS plugin phase runs.
build.onLoad(
{ filter: /.*/, namespace: 'bun-bundle-shim' },
() => ({
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
loader: 'js',
}),
)
build.onResolve( build.onResolve(
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ }, { filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
@@ -274,16 +327,7 @@ export const SeverityNumber = {};
// Scan source to find imports that can't resolve // Scan source to find imports that can't resolve
function scanForMissingImports() { function scanForMissingImports() {
function walk(dir: string) { function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const code: string = fs.readFileSync(full, 'utf-8')
// Collect all imports
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
const specifier = m[4]
const namedPart = m[1] || m[3] || ''
const names = namedPart.split(',') const names = namedPart.split(',')
.map((s: string) => s.trim().replace(/^type\s+/, '')) .map((s: string) => s.trim().replace(/^type\s+/, ''))
.filter((s: string) => s && !s.startsWith('type ')) .filter((s: string) => s && !s.startsWith('type '))
@@ -303,8 +347,7 @@ export const SeverityNumber = {};
} }
// Check relative .js imports // Check relative .js imports
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) { else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
const dir2 = pathMod.dirname(full) const resolved = pathMod.resolve(fileDir, specifier)
const resolved = pathMod.resolve(dir2, specifier)
const tsVariant = resolved.replace(/\.js$/, '.ts') const tsVariant = resolved.replace(/\.js$/, '.ts')
const tsxVariant = resolved.replace(/\.js$/, '.tsx') const tsxVariant = resolved.replace(/\.js$/, '.tsx')
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) { if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
@@ -318,6 +361,38 @@ export const SeverityNumber = {};
for (const n of names) missingModuleExports.get(specifier)!.add(n) for (const n of names) missingModuleExports.get(specifier)!.add(n)
} }
} }
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const rawCode: string = fs.readFileSync(full, 'utf-8')
const fileDir = pathMod.dirname(full)
// Strip comments before scanning for imports/requires.
// The regex scanner matches require()/import() patterns
// inside JSDoc comments, causing false-positive missing
// module detection that breaks the build with noop stubs.
const code = rawCode
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/.*$/gm, '') // line comments
// Collect static imports: import { X } from '...'
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
}
// Collect dynamic requires: require('...') — these are used
// behind feature() gates and become live when flags are enabled.
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
// Collect dynamic imports: import('...')
for (const m of code.matchAll(/import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
} }
} }
walk(srcDir) walk(srcDir)
@@ -389,7 +464,13 @@ if (!result.success) {
for (const log of result.logs) { for (const log of result.logs) {
console.error(log) console.error(log)
} }
process.exit(1) process.exitCode = 1
} else {
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
} }
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`) } finally {
// Always restore source files, even if Bun.build() throws
restoreModifiedFiles()
console.log(` 🔄 feature-flags: pre-processed ${numModified} files (restored)`)
}

View File

@@ -0,0 +1,146 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
// ---------------------------------------------------------------------------
// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to
// a temp .mjs file, and dynamically import it so we can test the real code
// that gets bundled.
// ---------------------------------------------------------------------------
const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8')
const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/)
if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts')
const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`)
const stubFile = join(testDir, 'growthbook-stub.mjs')
const flagsFile = join(testDir, 'test-flags.json')
mkdirSync(testDir, { recursive: true })
writeFileSync(stubFile, stubMatch[1])
// Point the stub at our test flags file (checked by _loadFlags on first access)
process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile
const stub = await import(stubFile)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('growthbook stub — local feature flag overrides', () => {
beforeEach(() => {
stub.resetGrowthBook()
try { unlinkSync(flagsFile) } catch { /* may not exist */ }
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
delete process.env.CLAUDE_FEATURE_FLAGS_FILE
})
// ── File absent ──────────────────────────────────────────────────
test('returns defaultValue when flags file is absent', () => {
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 42)).toBe(42)
})
test('getAllGrowthBookFeatures returns {} when file is absent', () => {
expect(stub.getAllGrowthBookFeatures()).toEqual({})
})
// ── Valid JSON object ────────────────────────────────────────────
test('loads and returns values from a valid JSON file', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true, tengu_bar: 'hello' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', false)).toBe(true)
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_bar', 'default')).toBe('hello')
})
test('returns defaultValue for keys not present in the file', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_missing', 99)).toBe(99)
})
test('getAllGrowthBookFeatures returns the full flags object', () => {
const flags = { tengu_a: true, tengu_b: false, tengu_c: 42 }
writeFileSync(flagsFile, JSON.stringify(flags))
expect(stub.getAllGrowthBookFeatures()).toEqual(flags)
})
// ── Malformed / non-object JSON ──────────────────────────────────
test('falls back to defaults on malformed JSON', () => {
writeFileSync(flagsFile, '{not valid json!!!')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
test('falls back to defaults when JSON is a primitive (true)', () => {
writeFileSync(flagsFile, 'true')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
test('falls back to defaults when JSON is an array', () => {
writeFileSync(flagsFile, '["a", "b"]')
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
})
// ── Cache invalidation ───────────────────────────────────────────
test('resetGrowthBook clears cache so the file is re-read', () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'first' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
// Update the file — cached value is still 'first'
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'second' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
// After reset, the new value is picked up
stub.resetGrowthBook()
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second')
})
test('refreshGrowthBookFeatures clears cache', async () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' }))
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1')
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' }))
await stub.refreshGrowthBookFeatures()
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2')
})
// ── Multiple getter variants ─────────────────────────────────────
test('all getter functions read from local flags', async () => {
writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } }))
expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true)
stub.resetGrowthBook()
expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true)
stub.resetGrowthBook()
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true)
stub.resetGrowthBook()
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true)
stub.resetGrowthBook()
expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 })
stub.resetGrowthBook()
expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 })
})
// ── Security gate ────────────────────────────────────────────────
test('checkSecurityRestrictionGate always returns false regardless of flags', async () => {
writeFileSync(flagsFile, JSON.stringify({
tengu_disable_bypass_permissions_mode: true,
}))
expect(await stub.checkSecurityRestrictionGate()).toBe(false)
})
})

View File

@@ -34,28 +34,55 @@ export function _resetForTesting() {}
`, `,
'services/analytics/growthbook': ` 'services/analytics/growthbook': `
import _fs from 'node:fs';
import _path from 'node:path';
import _os from 'node:os';
let _flags = undefined;
function _loadFlags() {
if (_flags !== undefined) return;
try {
const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE
|| _path.join(_os.homedir(), '.claude', 'feature-flags.json');
const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8'));
_flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
} catch {
_flags = null;
}
}
function _getFlagValue(key, defaultValue) {
_loadFlags();
if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key];
return defaultValue;
}
const noop = () => {}; const noop = () => {};
export function onGrowthBookRefresh() { return noop; } export function onGrowthBookRefresh() { return noop; }
export function hasGrowthBookEnvOverride() { return false; } export function hasGrowthBookEnvOverride() { return false; }
export function getAllGrowthBookFeatures() { return {}; } export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
export function getGrowthBookConfigOverrides() { return {}; } export function getGrowthBookConfigOverrides() { return {}; }
export function setGrowthBookConfigOverride() {} export function setGrowthBookConfigOverride() {}
export function clearGrowthBookConfigOverrides() {} export function clearGrowthBookConfigOverrides() {}
export function getApiBaseUrlHost() { return undefined; } export function getApiBaseUrlHost() { return undefined; }
export const initializeGrowthBook = async () => null; export const initializeGrowthBook = async () => null;
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; } export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; } export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; } export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; } export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); }
export async function checkSecurityRestrictionGate() { return false; } // Security killswitch — always false in the open build. Anthropic uses this
export async function checkGate_CACHED_OR_BLOCKING() { return false; } // gate to remotely disable bypassPermissions mode; exposing it via local flags
// would let users accidentally lock themselves out of --dangerously-skip-permissions.
export async function checkSecurityRestrictionGate(gate) { return false; }
export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); }
export function refreshGrowthBookAfterAuthChange() {} export function refreshGrowthBookAfterAuthChange() {}
export function resetGrowthBook() {} export function resetGrowthBook() { _flags = undefined; }
export async function refreshGrowthBookFeatures() {} export async function refreshGrowthBookFeatures() { _flags = undefined; }
export function setupPeriodicGrowthBookRefresh() {} export function setupPeriodicGrowthBookRefresh() {}
export function stopPeriodicGrowthBookRefresh() {} export function stopPeriodicGrowthBookRefresh() {}
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; } export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; } export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
`, `,
'services/analytics/sink': ` 'services/analytics/sink': `

View File

@@ -20,6 +20,23 @@ describe('formatReachabilityFailureDetail', () => {
) )
}) })
test('redacts credentials and sensitive query parameters in endpoint details', () => {
const detail = formatReachabilityFailureDetail(
'http://user:pass@localhost:11434/v1/models?token=abc123&mode=test',
502,
'bad gateway',
{
transport: 'chat_completions',
requestedModel: 'llama3.1:8b',
resolvedModel: 'llama3.1:8b',
},
)
expect(detail).toBe(
'Unexpected status 502 from http://redacted:redacted@localhost:11434/v1/models?token=redacted&mode=test. Body: bad gateway',
)
})
test('adds alias/entitlement hint for codex model support 400s', () => { test('adds alias/entitlement hint for codex model support 400s', () => {
const detail = formatReachabilityFailureDetail( const detail = formatReachabilityFailureDetail(
'https://chatgpt.com/backend-api/codex/responses', 'https://chatgpt.com/backend-api/codex/responses',

View File

@@ -7,6 +7,11 @@ import {
resolveProviderRequest, resolveProviderRequest,
isLocalProviderUrl as isProviderLocalUrl, isLocalProviderUrl as isProviderLocalUrl,
} from '../src/services/api/providerConfig.js' } from '../src/services/api/providerConfig.js'
import {
getLocalOpenAICompatibleProviderLabel,
probeOllamaGenerationReadiness,
} from '../src/utils/providerDiscovery.js'
import { redactUrlForDisplay } from '../src/utils/urlRedaction.js'
type CheckResult = { type CheckResult = {
ok: boolean ok: boolean
@@ -69,7 +74,7 @@ export function formatReachabilityFailureDetail(
}, },
): string { ): string {
const compactBody = responseBody.trim().replace(/\s+/g, ' ').slice(0, 240) const compactBody = responseBody.trim().replace(/\s+/g, ' ').slice(0, 240)
const base = `Unexpected status ${status} from ${endpoint}.` const base = `Unexpected status ${status} from ${redactUrlForDisplay(endpoint)}.`
const bodySuffix = compactBody ? ` Body: ${compactBody}` : '' const bodySuffix = compactBody ? ` Body: ${compactBody}` : ''
if (request.transport !== 'codex_responses' || status !== 400) { if (request.transport !== 'codex_responses' || status !== 400) {
@@ -255,7 +260,7 @@ function checkOpenAIEnv(): CheckResult[] {
results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL)) results.push(pass('OPENAI_MODEL', process.env.OPENAI_MODEL))
} }
results.push(pass('OPENAI_BASE_URL', request.baseUrl)) results.push(pass('OPENAI_BASE_URL', redactUrlForDisplay(request.baseUrl)))
if (request.transport === 'codex_responses') { if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(process.env) const credentials = resolveCodexApiCredentials(process.env)
@@ -308,7 +313,7 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).') return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).')
} }
if (useGithub) { if (useGithub && !useOpenAI) {
return pass( return pass(
'Provider reachability', 'Provider reachability',
'Skipped for GitHub Models (inference endpoint differs from OpenAI /models probe).', 'Skipped for GitHub Models (inference endpoint differs from OpenAI /models probe).',
@@ -326,6 +331,7 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
const endpoint = request.transport === 'codex_responses' const endpoint = request.transport === 'codex_responses'
? `${request.baseUrl}/responses` ? `${request.baseUrl}/responses`
: `${request.baseUrl}/models` : `${request.baseUrl}/models`
const redactedEndpoint = redactUrlForDisplay(endpoint)
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 4000) const timeout = setTimeout(() => controller.abort(), 4000)
@@ -375,7 +381,10 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
}) })
if (response.status === 200 || response.status === 401 || response.status === 403) { if (response.status === 200 || response.status === 401 || response.status === 403) {
return pass('Provider reachability', `Reached ${endpoint} (status ${response.status}).`) return pass(
'Provider reachability',
`Reached ${redactedEndpoint} (status ${response.status}).`,
)
} }
const responseBody = await response.text().catch(() => '') const responseBody = await response.text().catch(() => '')
@@ -391,12 +400,100 @@ async function checkBaseUrlReachability(): Promise<CheckResult> {
) )
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
return fail('Provider reachability', `Failed to reach ${endpoint}: ${message}`) return fail(
'Provider reachability',
`Failed to reach ${redactedEndpoint}: ${message}`,
)
} finally { } finally {
clearTimeout(timeout) clearTimeout(timeout)
} }
} }
async function checkProviderGenerationReadiness(): Promise<CheckResult> {
const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const useMistral = isTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
if (!useGemini && !useOpenAI && !useGithub && !useMistral) {
return pass('Provider generation readiness', 'Skipped (OpenAI-compatible mode disabled).')
}
if (useGithub && !useOpenAI) {
return pass(
'Provider generation readiness',
'Skipped for GitHub Models (runtime generation uses a different endpoint flow).',
)
}
if (useGemini || useMistral) {
return pass(
'Provider generation readiness',
'Skipped for managed provider mode.',
)
}
if (!useOpenAI) {
return pass('Provider generation readiness', 'Skipped (OpenAI-compatible mode disabled).')
}
const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL,
})
if (request.transport === 'codex_responses') {
return pass(
'Provider generation readiness',
'Skipped for Codex responses (reachability probe already performs a lightweight generation request).',
)
}
if (!isLocalBaseUrl(request.baseUrl)) {
return pass('Provider generation readiness', 'Skipped for non-local provider URL.')
}
const localProviderLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl)
if (localProviderLabel !== 'Ollama') {
return pass(
'Provider generation readiness',
`Skipped for ${localProviderLabel} (no provider-specific generation probe).`,
)
}
const readiness = await probeOllamaGenerationReadiness({
baseUrl: request.baseUrl,
model: request.requestedModel,
})
if (readiness.state === 'ready') {
return pass(
'Provider generation readiness',
`Generated a test response with ${readiness.probeModel ?? request.requestedModel}.`,
)
}
if (readiness.state === 'unreachable') {
return fail(
'Provider generation readiness',
`Could not reach Ollama at ${redactUrlForDisplay(request.baseUrl)}.`,
)
}
if (readiness.state === 'no_models') {
return fail(
'Provider generation readiness',
'Ollama is reachable, but no installed models were found. Pull a model first (for example: ollama pull qwen2.5-coder:7b).',
)
}
const detailSuffix = readiness.detail ? ` Detail: ${readiness.detail}.` : ''
return fail(
'Provider generation readiness',
`Ollama is reachable, but generation failed for ${readiness.probeModel ?? request.requestedModel}.${detailSuffix}`,
)
}
function isAtomicChatUrl(baseUrl: string): boolean { function isAtomicChatUrl(baseUrl: string): boolean {
try { try {
const parsed = new URL(baseUrl) const parsed = new URL(baseUrl)
@@ -567,6 +664,7 @@ async function main(): Promise<void> {
results.push(checkBuildArtifacts()) results.push(checkBuildArtifacts())
results.push(...checkOpenAIEnv()) results.push(...checkOpenAIEnv())
results.push(await checkBaseUrlReachability()) results.push(await checkBaseUrlReachability())
results.push(await checkProviderGenerationReadiness())
results.push(checkOllamaProcessorMode()) results.push(checkOllamaProcessorMode())
if (!options.json) { if (!options.json) {

View File

@@ -0,0 +1,282 @@
/**
* Tests for Bug Fixes applied to openclaude.
*
* Covers:
* 1. Gemini `store: false` rejection fix
* 2. Session timeout / 500 error fix (stream idle timeout)
* 3. Agent loop continuation nudge
* 4. Web search result count improvements
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..')
const file = (relative: string) => Bun.file(resolve(SRC, relative))
// ---------------------------------------------------------------------------
// Fix 1: Gemini `store: false` rejection
// ---------------------------------------------------------------------------
describe('Gemini store field fix', () => {
test('isGeminiMode is imported and used in openaiShim', async () => {
const content = await file('services/api/openaiShim.ts').text()
// Verify the fix: store deletion should check for Gemini mode
expect(content).toContain('isGeminiMode()')
expect(content).toContain("mistral and gemini don't recognize body.store")
// Ensure the delete body.store is guarded for both Mistral and Gemini
expect(content).toMatch(/isMistral\s*\|\|\s*isGeminiMode\(\)/)
})
test('store: false is still set by default (OpenAI needs it)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// The body should still have store: false by default
expect(content).toMatch(/store:\s*false/)
// But it should be deleted for non-OpenAI providers
expect(content).toMatch(/delete body\.store/)
})
})
// ---------------------------------------------------------------------------
// Fix 2: Session timeout — stream idle timeout
// ---------------------------------------------------------------------------
describe('Session timeout fix', () => {
test('openaiShim has idle timeout for SSE streams', async () => {
const content = await file('services/api/openaiShim.ts').text()
expect(content).toContain('STREAM_IDLE_TIMEOUT_MS')
expect(content).toContain('readWithTimeout')
expect(content).toMatch(/readWithTimeout\(\)/)
})
test('codexShim has idle timeout for SSE streams', async () => {
const content = await file('services/api/codexShim.ts').text()
expect(content).toContain('STREAM_IDLE_TIMEOUT_MS')
expect(content).toContain('readWithTimeout')
expect(content).toMatch(/readWithTimeout\(\)/)
})
test('idle timeout is set to a reasonable value (>= 60s)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// Extract the timeout value (supports numeric separators like 120_000)
const match = content.match(/STREAM_IDLE_TIMEOUT_MS\s*=\s*([\d_]+)/)
expect(match).not.toBeNull()
const timeoutMs = parseInt(match![1].replace(/_/g, ''), 10)
expect(timeoutMs).toBeGreaterThanOrEqual(60_000)
})
})
// ---------------------------------------------------------------------------
// Fix 3: Agent loop continuation nudge
// ---------------------------------------------------------------------------
describe('Agent loop continuation nudge', () => {
test('query.ts has continuation signal detection', async () => {
const content = await file('query.ts').text()
expect(content).toContain('continuationSignals')
expect(content).toContain('Continuation nudge triggered')
expect(content).toContain('continuation_nudge')
})
test('continuation signals include tightened patterns', async () => {
const content = await file('query.ts').text()
// Should detect tightened patterns requiring explicit action verbs
expect(content).toMatch(/so now \(i\|let me\|we\)/)
expect(content).toContain('completionMarkers')
expect(content).toContain('MAX_CONTINUATION_NUDGES')
// Verify the nudge counter guard exists
expect(content).toMatch(/continuationNudgeCount\s*<\s*MAX_CONTINUATION_NUDGES/)
})
test('nudge creates a meta user message to continue', async () => {
const content = await file('query.ts').text()
expect(content).toContain(
'Continue with the task. Use the appropriate tools to proceed.',
)
})
})
// ---------------------------------------------------------------------------
// Fix 4: Web search result count improvements
// ---------------------------------------------------------------------------
describe('Web search result count improvements', () => {
test('Bing provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/bing.ts',
).text()
expect(content).toMatch(/count.*['"]15['"]/)
})
test('Tavily provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/tavily.ts',
).text()
expect(content).toMatch(/max_results:\s*15/)
})
test('Exa provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/exa.ts',
).text()
expect(content).toMatch(/numResults:\s*15/)
})
test('Firecrawl provider requests at least 15 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/firecrawl.ts',
).text()
expect(content).toMatch(/limit:\s*15/)
})
test('Mojeek provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/mojeek.ts',
).text()
// Mojeek uses 't' param for result count — verify it's set to 10
expect(content).toMatch(/searchParams\.set\('t',\s*'10'\)/)
})
test('You.com provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/you.ts',
).text()
expect(content).toMatch(/num_web_results.*['"]10['"]/)
})
test('Jina provider requests at least 10 results', async () => {
const content = await file(
'tools/WebSearchTool/providers/jina.ts',
).text()
expect(content).toMatch(/count.*['"]10['"]/)
})
test('Native Anthropic web search max_uses increased to 15', async () => {
const content = await file(
'tools/WebSearchTool/WebSearchTool.ts',
).text()
expect(content).toMatch(/max_uses:\s*15/)
})
})
// ---------------------------------------------------------------------------
// Fix 5: MCP tool timeout fix
// ---------------------------------------------------------------------------
describe('MCP tool timeout fix', () => {
test('default MCP tool timeout is reasonable (not 27 hours)', async () => {
const content = await file('services/mcp/client.ts').text()
// Should NOT have the old ~27.8 hour default
expect(content).not.toContain('100_000_000')
// Should have a reasonable timeout (5 minutes = 300_000ms)
expect(content).toMatch(/DEFAULT_MCP_TOOL_TIMEOUT_MS\s*=\s*300_000/)
})
test('MCP tools/list has retry logic', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toContain('tools/list failed (attempt')
expect(content).toContain('Retrying...')
})
test('MCP URL elicitation checks abort signal', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toContain('signal.aborted')
expect(content).toContain('Tool call aborted during URL elicitation')
})
test('MCP tool error messages include server and tool name in telemetry', async () => {
const content = await file('services/mcp/client.ts').text()
// Telemetry message should include context like "MCP tool [serverName] toolName: error"
// The human-readable message stays unchanged to avoid breaking error consumers
expect(content).toContain('MCP tool [${name}] ${tool}:')
})
})
// ---------------------------------------------------------------------------
// Cross-cutting: verify no regressions
// ---------------------------------------------------------------------------
describe('Regression checks', () => {
test('store field is still set for OpenAI (not deleted unconditionally)', async () => {
const content = await file('services/api/openaiShim.ts').text()
// store: false should exist in body construction
expect(content).toMatch(/store:\s*false/)
// But delete body.store should be conditional (guarded by if)
const deleteLines = content.split('\n').filter(l => l.includes('delete body.store'))
expect(deleteLines.length).toBeGreaterThan(0)
// Verify the delete is inside a conditional block by checking surrounding context
for (const line of deleteLines) {
const trimmed = line.trim()
// Should be either inside an if block (indented delete) or a comment
expect(
trimmed.startsWith('delete') && !trimmed.includes('// unconditional'),
).toBe(true)
}
})
})
// ---------------------------------------------------------------------------
// Fix 6: SendMessageTool race condition guard
// ---------------------------------------------------------------------------
describe('SendMessageTool race condition fix', () => {
test('SendMessageTool has double-check for concurrent resume', async () => {
const content = await file('tools/SendMessageTool/SendMessageTool.ts').text()
// Should have a second status check before resuming to prevent race
expect(content).toContain('was concurrently resumed')
// The freshTask check should re-read from getAppState
expect(content).toMatch(/const freshTask = context\.getAppState\(\)\.tasks\[agentId\]/)
})
})
// ---------------------------------------------------------------------------
// Fix 7: AgentTool dump state cleanup
// ---------------------------------------------------------------------------
describe('AgentTool cleanup fix', () => {
test('backgrounded agent always cleans up dump state', async () => {
const content = await file('tools/AgentTool/AgentTool.tsx').text()
// The backgrounded agent's finally block should clean up regardless
// of whether the agent crashed or completed normally
expect(content).toContain('Defensive cleanup: wrap each call so one failure')
// Verify cleanup is wrapped in try/catch for defensive execution
expect(content).toMatch(/try\s*\{\s*clearInvokedSkillsForAgent/)
expect(content).toMatch(/try\s*\{\s*clearDumpState/)
})
})
// ---------------------------------------------------------------------------
// Fix 8: Context overflow 500 error handling
// ---------------------------------------------------------------------------
describe('Context overflow 500 fix', () => {
test('errors.ts has handler for context overflow 500 errors', async () => {
const content = await file('services/api/errors.ts').text()
expect(content).toContain('500 errors caused by context overflow')
expect(content).toContain('too many tokens')
expect(content).toContain('The conversation has grown too large')
})
test('query.ts has circuit breaker safety net for oversized context', async () => {
const content = await file('query.ts').text()
expect(content).toContain('Safety net: when auto-compact')
expect(content).toContain('circuit breaker has tripped')
expect(content).toContain('automatic compaction has failed')
})
})

View File

@@ -0,0 +1,55 @@
/**
* Tests for Web Search Provider result count configurations.
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..', 'tools', 'WebSearchTool', 'providers')
const file = (name: string) => Bun.file(resolve(SRC, name))
describe('Provider result counts', () => {
const providers = [
'bing.ts',
'tavily.ts',
'exa.ts',
'firecrawl.ts',
'mojeek.ts',
'you.ts',
'jina.ts',
'duckduckgo.ts',
// linkup.ts excluded — uses depth param, not a result count field
]
for (const name of providers) {
test(`${name} exists and is readable`, async () => {
const f = file(name)
expect(await f.exists()).toBe(true)
const content = await f.text()
expect(content.length).toBeGreaterThan(100)
})
}
test('No provider hardcodes a limit below 10', async () => {
const suspiciousPatterns = [
/count['":\s]*['"]([1-9])['"]/i,
/limit['":\s]*([1-9])\b/,
/max_results['":\s]*([1-9])\b/,
/numResults['":\s]*([1-9])\b/,
]
for (const name of providers) {
const content = await file(name).text()
for (const pattern of suspiciousPatterns) {
const match = content.match(pattern)
if (match) {
const num = parseInt(match[1], 10)
expect(num).toBeGreaterThanOrEqual(
10,
`${name} has suspiciously low result count: ${match[0]}`,
)
}
}
}
})
})

View File

@@ -0,0 +1,191 @@
/**
* Security hardening regression tests.
*
* Covers:
* 1. MCP tool result Unicode sanitization
* 2. Sandbox settings source filtering (exclude projectSettings)
* 3. Plugin git clone/pull hooks disabled
* 4. ANTHROPIC_FOUNDRY_API_KEY removed from SAFE_ENV_VARS
* 5. WebFetch SSRF protection via ssrfGuardedLookup
*/
import { describe, test, expect } from 'bun:test'
import { resolve } from 'path'
const SRC = resolve(import.meta.dir, '..')
const file = (relative: string) => Bun.file(resolve(SRC, relative))
// ---------------------------------------------------------------------------
// Fix 1: MCP tool result Unicode sanitization
// ---------------------------------------------------------------------------
describe('MCP tool result sanitization', () => {
test('transformResultContent sanitizes text content', async () => {
const content = await file('services/mcp/client.ts').text()
// Tool definitions are already sanitized (line ~1798)
expect(content).toContain('recursivelySanitizeUnicode(result.tools)')
// Tool results must also be sanitized
expect(content).toMatch(
/case 'text':[\s\S]*?recursivelySanitizeUnicode\(resultContent\.text\)/,
)
})
test('resource text content is also sanitized', async () => {
const content = await file('services/mcp/client.ts').text()
expect(content).toMatch(
/recursivelySanitizeUnicode\(\s*`\$\{prefix\}\$\{resource\.text\}`/,
)
})
})
// ---------------------------------------------------------------------------
// Fix 2: Sandbox settings source filtering
// ---------------------------------------------------------------------------
describe('Sandbox settings trust boundary', () => {
test('getSandboxEnabledSetting does not use getSettings_DEPRECATED', async () => {
const content = await file('utils/sandbox/sandbox-adapter.ts').text()
// Extract the getSandboxEnabledSetting function body
const fnMatch = content.match(
/function getSandboxEnabledSetting\(\)[^{]*\{([\s\S]*?)\n\}/,
)
expect(fnMatch).not.toBeNull()
const fnBody = fnMatch![1]
// Must NOT use getSettings_DEPRECATED (reads all sources including project)
expect(fnBody).not.toContain('getSettings_DEPRECATED')
// Must use getSettingsForSource for individual trusted sources
expect(fnBody).toContain("getSettingsForSource('userSettings')")
expect(fnBody).toContain("getSettingsForSource('policySettings')")
// Must NOT read from projectSettings
expect(fnBody).not.toContain("'projectSettings'")
})
})
// ---------------------------------------------------------------------------
// Fix 3: Plugin git hooks disabled
// ---------------------------------------------------------------------------
describe('Plugin git operations disable hooks', () => {
test('gitClone includes core.hooksPath=/dev/null', async () => {
const content = await file('utils/plugins/marketplaceManager.ts').text()
// The clone args must disable hooks
const cloneSection = content.slice(
content.indexOf('export async function gitClone('),
content.indexOf('export async function gitClone(') + 2000,
)
expect(cloneSection).toContain("'core.hooksPath=/dev/null'")
})
test('gitPull includes core.hooksPath=/dev/null', async () => {
const content = await file('utils/plugins/marketplaceManager.ts').text()
const pullSection = content.slice(
content.indexOf('export async function gitPull('),
content.indexOf('export async function gitPull(') + 2000,
)
expect(pullSection).toContain("'core.hooksPath=/dev/null'")
})
test('gitSubmoduleUpdate includes core.hooksPath=/dev/null', async () => {
const content = await file('utils/plugins/marketplaceManager.ts').text()
const subSection = content.slice(
content.indexOf('async function gitSubmoduleUpdate('),
content.indexOf('async function gitSubmoduleUpdate(') + 1000,
)
expect(subSection).toContain("'core.hooksPath=/dev/null'")
})
})
// ---------------------------------------------------------------------------
// Fix 4: ANTHROPIC_FOUNDRY_API_KEY not in SAFE_ENV_VARS
// ---------------------------------------------------------------------------
describe('SAFE_ENV_VARS excludes credentials', () => {
test('ANTHROPIC_FOUNDRY_API_KEY is not in SAFE_ENV_VARS', async () => {
const content = await file('utils/managedEnvConstants.ts').text()
// Extract the SAFE_ENV_VARS set definition
const safeStart = content.indexOf('export const SAFE_ENV_VARS')
const safeEnd = content.indexOf('])', safeStart)
const safeSection = content.slice(safeStart, safeEnd)
expect(safeSection).not.toContain('ANTHROPIC_FOUNDRY_API_KEY')
})
})
// ---------------------------------------------------------------------------
// Fix 5: WebFetch SSRF protection
// ---------------------------------------------------------------------------
describe('WebFetch SSRF guard', () => {
test('getWithPermittedRedirects uses ssrfGuardedLookup', async () => {
const content = await file('tools/WebFetchTool/utils.ts').text()
expect(content).toContain(
"import { ssrfGuardedLookup } from '../../utils/hooks/ssrfGuard.js'",
)
// The axios.get call in getWithPermittedRedirects must include lookup
const fnSection = content.slice(
content.indexOf('export async function getWithPermittedRedirects('),
content.indexOf('export async function getWithPermittedRedirects(') +
1000,
)
expect(fnSection).toContain('lookup: ssrfGuardedLookup')
})
})
// ---------------------------------------------------------------------------
// Fix 6: Swarm permission file polling removed (security hardening)
// ---------------------------------------------------------------------------
describe('Swarm permission file polling removed', () => {
test('useSwarmPermissionPoller hook no longer exists', async () => {
const content = await file(
'hooks/useSwarmPermissionPoller.ts',
).text()
// The file-based polling hook must not exist — it read from an
// unauthenticated resolved/ directory where any local process could
// forge approval files.
expect(content).not.toContain('function useSwarmPermissionPoller(')
// The file-based processResponse must not exist
expect(content).not.toContain('function processResponse(')
})
test('poller does not import from permissionSync', async () => {
const content = await file(
'hooks/useSwarmPermissionPoller.ts',
).text()
// Must not import anything from permissionSync — all file-based
// functions have been removed from this module's dependencies
expect(content).not.toContain('permissionSync')
})
test('file-based permission functions are marked deprecated', async () => {
const content = await file(
'utils/swarm/permissionSync.ts',
).text()
// All file-based functions must have @deprecated JSDoc
const deprecatedFns = [
'writePermissionRequest',
'readPendingPermissions',
'readResolvedPermission',
'resolvePermission',
'pollForResponse',
'removeWorkerResponse',
]
for (const fn of deprecatedFns) {
// Find the function and check that @deprecated appears before it
const fnIndex = content.indexOf(`export async function ${fn}(`)
if (fnIndex === -1) continue // submitPermissionRequest is a const, not async function
const preceding = content.slice(Math.max(0, fnIndex - 500), fnIndex)
expect(preceding).toContain('@deprecated')
}
})
test('mailbox-based functions are NOT deprecated', async () => {
const content = await file(
'utils/swarm/permissionSync.ts',
).text()
// These are the active path — must not be deprecated
const activeFns = [
'sendPermissionRequestViaMailbox',
'sendPermissionResponseViaMailbox',
]
for (const fn of activeFns) {
const fnIndex = content.indexOf(`export async function ${fn}(`)
expect(fnIndex).not.toBe(-1)
const preceding = content.slice(Math.max(0, fnIndex - 300), fnIndex)
expect(preceding).not.toContain('@deprecated')
}
})
})

View File

@@ -1562,29 +1562,8 @@ export function clearInvokedSkillsForAgent(agentId: string): void {
} }
} }
// Slow operations tracking for dev bar // Slow operations tracking removed (was internal-only).
const MAX_SLOW_OPERATIONS = 10 // Functions kept as no-ops to avoid breaking callers.
const SLOW_OPERATION_TTL_MS = 10000
export function addSlowOperation(operation: string, durationMs: number): void {
if (process.env.USER_TYPE !== 'ant') return
// Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
// These are intentionally slow since the user is drafting text
if (operation.includes('exec') && operation.includes('claude-prompt-')) {
return
}
const now = Date.now()
// Remove stale operations
STATE.slowOperations = STATE.slowOperations.filter(
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
)
// Add new operation
STATE.slowOperations.push({ operation, durationMs, timestamp: now })
// Keep only the most recent operations
if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
}
}
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
operation: string operation: string
@@ -1592,32 +1571,17 @@ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
timestamp: number timestamp: number
}> = [] }> = []
export function addSlowOperation(
_operation: string,
_durationMs: number,
): void {}
export function getSlowOperations(): ReadonlyArray<{ export function getSlowOperations(): ReadonlyArray<{
operation: string operation: string
durationMs: number durationMs: number
timestamp: number timestamp: number
}> { }> {
// Most common case: nothing tracked. Return a stable reference so the
// caller's setState() can bail via Object.is instead of re-rendering at 2fps.
if (STATE.slowOperations.length === 0) {
return EMPTY_SLOW_OPERATIONS return EMPTY_SLOW_OPERATIONS
}
const now = Date.now()
// Only allocate a new array when something actually expired; otherwise keep
// the reference stable across polls while ops are still fresh.
if (
STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
) {
STATE.slowOperations = STATE.slowOperations.filter(
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
)
if (STATE.slowOperations.length === 0) {
return EMPTY_SLOW_OPERATIONS
}
}
// Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
// before pushing, so the array held in React state is never mutated.
return STATE.slowOperations
} }
export function getMainThreadAgentType(): string | undefined { export function getMainThreadAgentType(): string | undefined {

View File

@@ -14,21 +14,14 @@
import { getOauthConfig } from '../constants/oauth.js' import { getOauthConfig } from '../constants/oauth.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js' import { getClaudeAIOAuthTokens } from '../utils/auth.js'
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */ /** Dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
export function getBridgeTokenOverride(): string | undefined { export function getBridgeTokenOverride(): string | undefined {
return ( return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
(process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
undefined
)
} }
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */ /** Dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
export function getBridgeBaseUrlOverride(): string | undefined { export function getBridgeBaseUrlOverride(): string | undefined {
return ( return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
undefined
)
} }
/** /**

View File

@@ -2194,14 +2194,10 @@ export async function bridgeMain(args: string[]): Promise<void> {
// Session ingress URL for WebSocket connections. In production this is the // Session ingress URL for WebSocket connections. In production this is the
// same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress). // same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress).
// Locally, session-ingress runs on a different port (9413) than the // Locally, session-ingress may run on a different port, so
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be // CLAUDE_BRIDGE_SESSION_INGRESS_URL can override the default.
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
const sessionIngressUrl = const sessionIngressUrl =
process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import( const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js' '../utils/git.js'
@@ -2851,10 +2847,7 @@ export async function runBridgeHeadless(
) )
} }
const sessionIngressUrl = const sessionIngressUrl =
process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import( const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js' '../utils/git.js'

View File

@@ -217,25 +217,39 @@ export async function getBridgeSession(
} }
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
const timeoutMs = 10_000
logForDebugging(`[bridge] Fetching session ${sessionId}`) logForDebugging(`[bridge] Fetching session ${sessionId}`)
let response let response
try { try {
response = await axios.get<{ environment_id?: string; title?: string }>( response = await axios.get<{ environment_id?: string; title?: string }>(
url, url,
{ headers, timeout: 10_000, validateStatus: s => s < 500 }, { headers, timeout: timeoutMs, validateStatus: s => s < 500 },
) )
} catch (err: unknown) { } catch (err: unknown) {
if (axios.isAxiosError(err)) {
const status = err.response?.status ?? 'no-response'
const code = err.code ?? 'unknown-code'
const requestUrl = err.config?.url ?? url
const method = err.config?.method?.toUpperCase() ?? 'GET'
const message = err.message ?? errorMessage(err)
const timeout = err.config?.timeout ?? timeoutMs
logForDebugging( logForDebugging(
`[bridge] Session fetch request failed: ${errorMessage(err)}`, `[bridge] Session fetch request failed: status=${status} code=${code} method=${method} url=${requestUrl} timeout=${timeout} message=${message}`,
) )
} else {
logForDebugging(
`[bridge] Session fetch request failed: url=${url} timeout=${timeoutMs} message=${errorMessage(err)}`,
)
}
return null return null
} }
if (response.status !== 200) { if (response.status !== 200) {
const detail = extractErrorDetail(response.data) const detail = extractErrorDetail(response.data)
logForDebugging( logForDebugging(
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`, `[bridge] Session fetch failed with status ${response.status} url=${url}${detail ? `: ${detail}` : ''}`,
) )
return null return null
} }

View File

@@ -465,10 +465,7 @@ export async function initReplBridge(
const branch = await getBranch() const branch = await getBranch()
const gitRepoUrl = await getRemoteUrl() const gitRepoUrl = await getRemoteUrl()
const sessionIngressUrl = const sessionIngressUrl =
process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
// Assistant-mode sessions advertise a distinct worker_type so the web UI // Assistant-mode sessions advertise a distinct worker_type so the web UI
// can filter them into a dedicated picker. KAIROS guard keeps the // can filter them into a dedicated picker. KAIROS guard keeps the

View File

@@ -11,7 +11,12 @@ import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopI
import { render } from '../../ink.js'; import { render } from '../../ink.js';
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js'; import {
clearMcpClientConfig,
clearServerTokensFromSecureStorage,
readClientSecret,
saveMcpClientSecret,
} from '../../services/mcp/auth.js'
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js'; import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js'; import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';

View File

@@ -362,15 +362,9 @@ const proactiveModule =
feature('PROACTIVE') || feature('KAIROS') feature('PROACTIVE') || feature('KAIROS')
? (require('../proactive/index.js') as typeof import('../proactive/index.js')) ? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
: null : null
const cronSchedulerModule = feature('AGENT_TRIGGERS') const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')) const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
: null const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')
const cronJitterConfigModule = feature('AGENT_TRIGGERS')
? (require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js'))
: null
const cronGate = feature('AGENT_TRIGGERS')
? (require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js'))
: null
const extractMemoriesModule = feature('EXTRACT_MEMORIES') const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null : null
@@ -2701,11 +2695,7 @@ function runHeadlessStreaming(
// the end of run() picks up the queued command. // the end of run() picks up the queued command.
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null null
if ( if (cronGate.isKairosCronEnabled()) {
feature('AGENT_TRIGGERS') &&
cronSchedulerModule &&
cronGate?.isKairosCronEnabled()
) {
cronScheduler = cronSchedulerModule.createCronScheduler({ cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => { onFire: prompt => {
if (inputClosed) return if (inputClosed) return
@@ -2727,8 +2717,8 @@ function runHeadlessStreaming(
void run() void run()
}, },
isLoading: () => running || inputClosed, isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig, getJitterConfig: cronJitterConfigModule.getCronJitterConfig,
isKilled: () => !cronGate?.isKairosCronEnabled(), isKilled: () => !cronGate.isKairosCronEnabled(),
}) })
cronScheduler.start() cronScheduler.start()
} }
@@ -4592,7 +4582,7 @@ function handleSetPermissionMode(
subtype: 'error', subtype: 'error',
request_id: requestId, request_id: requestId,
error: error:
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions', 'Cannot set permission mode to bypassPermissions. Enable it with --allow-dangerously-skip-permissions or set permissions.allowBypassPermissionsMode in settings.json',
}, },
}) })
return toolPermissionContext return toolPermissionContext

30
src/commands.test.ts Normal file
View File

@@ -0,0 +1,30 @@
import { formatDescriptionWithSource } from './commands.js'
describe('formatDescriptionWithSource', () => {
test('returns empty text for prompt commands missing a description', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'builtin',
description: undefined,
} as any
expect(formatDescriptionWithSource(command)).toBe('')
})
test('formats plugin commands with missing description safely', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'plugin',
description: undefined,
pluginInfo: {
pluginManifest: {
name: 'MyPlugin',
},
},
} as any
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
})
})

View File

@@ -740,23 +740,23 @@ export function getCommand(commandName: string, commands: Command[]): Command {
*/ */
export function formatDescriptionWithSource(cmd: Command): string { export function formatDescriptionWithSource(cmd: Command): string {
if (cmd.type !== 'prompt') { if (cmd.type !== 'prompt') {
return cmd.description return cmd.description ?? ''
} }
if (cmd.kind === 'workflow') { if (cmd.kind === 'workflow') {
return `${cmd.description} (workflow)` return `${cmd.description ?? ''} (workflow)`
} }
if (cmd.source === 'plugin') { if (cmd.source === 'plugin') {
const pluginName = cmd.pluginInfo?.pluginManifest.name const pluginName = cmd.pluginInfo?.pluginManifest.name
if (pluginName) { if (pluginName) {
return `(${pluginName}) ${cmd.description}` return `(${pluginName}) ${cmd.description ?? ''}`
} }
return `${cmd.description} (plugin)` return `${cmd.description ?? ''} (plugin)`
} }
if (cmd.source === 'builtin' || cmd.source === 'mcp') { if (cmd.source === 'builtin' || cmd.source === 'mcp') {
return cmd.description return cmd.description ?? ''
} }
if (cmd.source === 'bundled') { if (cmd.source === 'bundled') {

View File

@@ -45,7 +45,7 @@ function getPromptContent(
<!-- CHANGELOG:END -->` <!-- CHANGELOG:END -->`
let slackStep = ` let slackStep = `
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.` 5. After creating/updating the PR, check if the user's AGENTS.md or CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
if (process.env.USER_TYPE === 'ant' && isUndercover()) { if (process.env.USER_TYPE === 'ant' && isUndercover()) {
prefix = getUndercoverInstructions() + '\n' prefix = getUndercoverInstructions() + '\n'
reviewerArg = '' reviewerArg = ''

43
src/commands/init.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { afterEach, expect, mock, test } from 'bun:test'
const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT
async function importInitCommand() {
return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default
}
afterEach(() => {
mock.restore()
if (originalClaudeCodeNewInit === undefined) {
delete process.env.CLAUDE_CODE_NEW_INIT
} else {
process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit
}
})
test('NEW_INIT prompt preserves existing root CLAUDE.md by default', async () => {
process.env.CLAUDE_CODE_NEW_INIT = '1'
mock.module('../projectOnboardingState.js', () => ({
maybeMarkProjectOnboardingComplete: () => {},
}))
mock.module('./initMode.js', () => ({
isNewInitEnabled: () => true,
}))
const command = await importInitCommand()
const blocks = await command.getPromptForCommand()
expect(blocks).toHaveLength(1)
expect(blocks[0]?.type).toBe('text')
expect(String(blocks[0]?.text)).toContain(
'checked-in root `CLAUDE.md` and does NOT already have a root `AGENTS.md`',
)
expect(String(blocks[0]?.text)).toContain(
'do NOT silently create a second root instruction file',
)
expect(String(blocks[0]?.text)).toContain(
'update the existing root `CLAUDE.md` in place by default',
)
})

View File

@@ -1,7 +1,6 @@
import { feature } from 'bun:bundle'
import type { Command } from '../commands.js' import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { isEnvTruthy } from '../utils/envUtils.js' import { isNewInitEnabled } from './initMode.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository. const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
@@ -25,19 +24,19 @@ Usage notes:
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\`` \`\`\``
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it. const NEW_INIT_PROMPT = `Set up a minimal AGENTS.md (and optionally CLAUDE.local.md, skills, and hooks) for this repo. The root project instruction file is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
## Phase 1: Ask what to set up ## Phase 1: Ask what to set up
Use AskUserQuestion to find out what the user wants: Use AskUserQuestion to find out what the user wants:
- "Which CLAUDE.md files should /init set up?" - "Which instruction files should /init set up?"
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal" Options: "Project AGENTS.md" | "Personal CLAUDE.local.md" | "Both project + personal"
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows." Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks." Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
- "Also set up skills and hooks?" - "Also set up skills and hooks?"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md" Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just the instruction file(s)"
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge." Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them." Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
@@ -59,24 +58,24 @@ Note what you could NOT figure out from code alone — these become interview qu
## Phase 3: Fill in the gaps ## Phase 3: Fill in the gaps
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer. Use AskUserQuestion to gather what you still need to write good instruction files and skills. Ask only things the code can't answer.
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices. If the user chose project AGENTS.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions: If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding") - What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth) - How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know? - Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file. - Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project AGENTS.md — that would check a personal reference into the team-shared file.
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end") - Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**: **Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a project verification workflow if tests exist, an AGENTS.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file. - **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys. - **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs". - **AGENTS.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into. **Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or an AGENTS.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes an AGENTS.md note. Never propose an artifact type the user didn't opt into.
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as: **Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
@@ -86,17 +85,19 @@ If the user chose personal CLAUDE.local.md or both: ask about them, not the code
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse • **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
• **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\` • **Verification workflow** (on-demand) — \`make lint && make typecheck && make test\`
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done" • **AGENTS.md note** (guideline) — "run lint/typecheck/test before marking done"
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all. - Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue. **Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
## Phase 4: Write CLAUDE.md (if user chose project or both) ## Phase 4: Write AGENTS.md (if user chose project or both)
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it. Write a minimal AGENTS.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5. If the repo already has a checked-in root \`CLAUDE.md\` and does NOT already have a root \`AGENTS.md\`, do NOT silently create a second root instruction file. In that case, update the existing root \`CLAUDE.md\` in place by default. Only create or migrate to root \`AGENTS.md\` if the user explicitly asks to migrate.
**Consume \`note\` entries from the Phase 3 preference queue whose target is AGENTS.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
Include: Include:
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences) - Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
@@ -111,7 +112,7 @@ Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase) - File-by-file structure or component lists (Claude can discover these by reading the codebase)
- Standard language conventions Claude already knows - Standard language conventions Claude already knows
- Generic advice ("write clean code", "handle errors") - Generic advice ("write clean code", "handle errors")
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md - Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating AGENTS.md
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version - Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill) - Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest") - Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
@@ -123,20 +124,20 @@ Do not repeat yourself and do not make up sections like "Common Development Task
Prefix the file with: Prefix the file with:
\`\`\` \`\`\`
# CLAUDE.md # AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\` \`\`\`
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite. If AGENTS.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter. For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside AGENTS.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants. For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory AGENTS.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
## Phase 5: Write CLAUDE.local.md (if user chose personal or both) ## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private. Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside AGENTS.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries. **Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
@@ -147,7 +148,7 @@ Include:
Keep it short — only include what would make Claude's responses noticeably better for this user. Keep it short — only include what would make Claude's responses noticeably better for this user.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically. If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project AGENTS.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite. If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
@@ -183,7 +184,7 @@ Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For w
## Phase 7: Suggest additional optimizations ## Phase 7: Suggest additional optimizations
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place. Tell the user you're going to suggest a few additional optimizations now that AGENTS.md and skills (if chosen) are in place.
Check the environment and ask about each gap you find (use AskUserQuestion): Check the environment and ask about each gap you find (use AskUserQuestion):
@@ -195,7 +196,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion):
For each hook preference (from the queue or the formatter fallback): For each hook preference (from the queue or the formatter fallback):
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook. 1. Target file: default based on the Phase 1 instruction-file choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
2. Pick the event and matcher from the preference: 2. Pick the event and matcher from the preference:
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\` - "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
@@ -227,11 +228,9 @@ const command = {
type: 'prompt', type: 'prompt',
name: 'init', name: 'init',
get description() { get description() {
return feature('NEW_INIT') && return isNewInitEnabled()
(process.env.USER_TYPE === 'ant' || ? 'Initialize new project instruction file(s) and optional skills/hooks with codebase documentation'
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)) : 'Initialize a new project instruction file with codebase documentation'
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new CLAUDE.md file with codebase documentation'
}, },
contentLength: 0, // Dynamic content contentLength: 0, // Dynamic content
progressMessage: 'analyzing your codebase', progressMessage: 'analyzing your codebase',
@@ -242,12 +241,7 @@ const command = {
return [ return [
{ {
type: 'text', type: 'text',
text: text: isNewInitEnabled() ? NEW_INIT_PROMPT : OLD_INIT_PROMPT,
feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? NEW_INIT_PROMPT
: OLD_INIT_PROMPT,
}, },
] ]
}, },

13
src/commands/initMode.ts Normal file
View File

@@ -0,0 +1,13 @@
import { feature } from 'bun:bundle'
import { isEnvTruthy } from '../utils/envUtils.js'
export function isNewInitEnabled(): boolean {
if (feature('NEW_INIT')) {
return (
process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)
)
}
return false
}

View File

@@ -1,17 +1,12 @@
import { execFileSync } from 'child_process' import { execFileSync } from 'child_process'
import { diffLines } from 'diff' import { diffLines } from 'diff'
import { constants as fsConstants } from 'fs'
import { import {
copyFile,
mkdir, mkdir,
mkdtemp,
readdir, readdir,
readFile, readFile,
rm,
unlink, unlink,
writeFile, writeFile,
} from 'fs/promises' } from 'fs/promises'
import { tmpdir } from 'os'
import { extname, join } from 'path' import { extname, join } from 'path'
import type { Command } from '../commands.js' import type { Command } from '../commands.js'
import { queryWithModel } from '../services/api/claude.js' import { queryWithModel } from '../services/api/claude.js'
@@ -22,7 +17,6 @@ import {
import type { LogOption } from '../types/logs.js' import type { LogOption } from '../types/logs.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { toError } from '../utils/errors.js' import { toError } from '../utils/errors.js'
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
import { logError } from '../utils/log.js' import { logError } from '../utils/log.js'
import { extractTextContent } from '../utils/messages.js' import { extractTextContent } from '../utils/messages.js'
import { getDefaultOpusModel } from '../utils/model/model.js' import { getDefaultOpusModel } from '../utils/model/model.js'
@@ -47,180 +41,6 @@ function getInsightsModel(): string {
return getDefaultOpusModel() return getDefaultOpusModel()
} }
// ============================================================================
// Homespace Data Collection
// ============================================================================
type RemoteHostInfo = {
name: string
sessionCount: number
}
/* eslint-disable custom-rules/no-process-env-top-level */
const getRunningRemoteHosts: () => Promise<string[]> =
process.env.USER_TYPE === 'ant'
? async () => {
const { stdout, code } = await execFileNoThrow(
'coder',
['list', '-o', 'json'],
{ timeout: 30000 },
)
if (code !== 0) return []
try {
const workspaces = jsonParse(stdout) as Array<{
name: string
latest_build?: { status?: string }
}>
return workspaces
.filter(w => w.latest_build?.status === 'running')
.map(w => w.name)
} catch {
return []
}
}
: async () => []
const getRemoteHostSessionCount: (hs: string) => Promise<number> =
process.env.USER_TYPE === 'ant'
? async (homespace: string) => {
const { stdout, code } = await execFileNoThrow(
'ssh',
[
`${homespace}.coder`,
'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l',
],
{ timeout: 30000 },
)
if (code !== 0) return 0
return parseInt(stdout.trim(), 10) || 0
}
: async () => 0
const collectFromRemoteHost: (
hs: string,
destDir: string,
) => Promise<{ copied: number; skipped: number }> =
process.env.USER_TYPE === 'ant'
? async (homespace: string, destDir: string) => {
const result = { copied: 0, skipped: 0 }
// Create temp directory
const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-'))
try {
// SCP the projects folder
const scpResult = await execFileNoThrow(
'scp',
['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir],
{ timeout: 300000 },
)
if (scpResult.code !== 0) {
// SCP failed
return result
}
const projectsDir = join(tempDir, 'projects')
let projectDirents: Awaited<ReturnType<typeof readdir>>
try {
projectDirents = await readdir(projectsDir, { withFileTypes: true })
} catch {
return result
}
// Merge into destination (parallel per project directory)
await Promise.all(
projectDirents.map(async dirent => {
const projectName = dirent.name
const projectPath = join(projectsDir, projectName)
// Skip if not a directory
if (!dirent.isDirectory()) return
const destProjectName = `${projectName}__${homespace}`
const destProjectPath = join(destDir, destProjectName)
try {
await mkdir(destProjectPath, { recursive: true })
} catch {
// Directory may already exist
}
// Copy session files (skip existing)
let files: Awaited<ReturnType<typeof readdir>>
try {
files = await readdir(projectPath, { withFileTypes: true })
} catch {
return
}
await Promise.all(
files.map(async fileDirent => {
const fileName = fileDirent.name
if (!fileName.endsWith('.jsonl')) return
const srcFile = join(projectPath, fileName)
const destFile = join(destProjectPath, fileName)
try {
await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL)
result.copied++
} catch {
// EEXIST from COPYFILE_EXCL means dest already exists
result.skipped++
}
}),
)
}),
)
} finally {
try {
await rm(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
return result
}
: async () => ({ copied: 0, skipped: 0 })
const collectAllRemoteHostData: (destDir: string) => Promise<{
hosts: RemoteHostInfo[]
totalCopied: number
totalSkipped: number
}> =
process.env.USER_TYPE === 'ant'
? async (destDir: string) => {
const rHosts = await getRunningRemoteHosts()
const result: RemoteHostInfo[] = []
let totalCopied = 0
let totalSkipped = 0
// Collect from all hosts in parallel (SCP per host can take seconds)
const hostResults = await Promise.all(
rHosts.map(async hs => {
const sessionCount = await getRemoteHostSessionCount(hs)
if (sessionCount > 0) {
const { copied, skipped } = await collectFromRemoteHost(
hs,
destDir,
)
return { name: hs, sessionCount, copied, skipped }
}
return { name: hs, sessionCount, copied: 0, skipped: 0 }
}),
)
for (const hr of hostResults) {
result.push({ name: hr.name, sessionCount: hr.sessionCount })
totalCopied += hr.copied
totalSkipped += hr.skipped
}
return { hosts: result, totalCopied, totalSkipped }
}
: async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 })
/* eslint-enable custom-rules/no-process-env-top-level */
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
@@ -2659,7 +2479,6 @@ export type InsightsExport = {
claude_code_version: string claude_code_version: string
date_range: { start: string; end: string } date_range: { start: string; end: string }
session_count: number session_count: number
remote_hosts_collected?: string[]
} }
aggregated_data: AggregatedData aggregated_data: AggregatedData
insights: InsightResults insights: InsightResults
@@ -2680,14 +2499,9 @@ export function buildExportData(
data: AggregatedData, data: AggregatedData,
insights: InsightResults, insights: InsightResults,
facets: Map<string, SessionFacets>, facets: Map<string, SessionFacets>,
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
): InsightsExport { ): InsightsExport {
const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown' const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown'
const remote_hosts_collected = remoteStats?.hosts
.filter(h => h.sessionCount > 0)
.map(h => h.name)
const facets_summary = { const facets_summary = {
total: facets.size, total: facets.size,
goal_categories: {} as Record<string, number>, goal_categories: {} as Record<string, number>,
@@ -2725,10 +2539,6 @@ export function buildExportData(
claude_code_version: version, claude_code_version: version,
date_range: data.date_range, date_range: data.date_range,
session_count: data.total_sessions, session_count: data.total_sessions,
...(remote_hosts_collected &&
remote_hosts_collected.length > 0 && {
remote_hosts_collected,
}),
}, },
aggregated_data: data, aggregated_data: data,
insights, insights,
@@ -2793,24 +2603,12 @@ async function scanAllSessions(): Promise<LiteSessionInfo[]> {
// Main Function // Main Function
// ============================================================================ // ============================================================================
export async function generateUsageReport(options?: { export async function generateUsageReport(): Promise<{
collectRemote?: boolean
}): Promise<{
insights: InsightResults insights: InsightResults
htmlPath: string htmlPath: string
data: AggregatedData data: AggregatedData
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
facets: Map<string, SessionFacets> facets: Map<string, SessionFacets>
}> { }> {
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
// Optionally collect data from remote hosts first (internal-only)
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
const destDir = join(getClaudeConfigHomeDir(), 'projects')
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
remoteStats = { hosts, totalCopied }
}
// Phase 1: Lite scan — filesystem metadata only (no JSONL parsing) // Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
const allScannedSessions = await scanAllSessions() const allScannedSessions = await scanAllSessions()
const totalSessionsScanned = allScannedSessions.length const totalSessionsScanned = allScannedSessions.length
@@ -3017,7 +2815,6 @@ export async function generateUsageReport(options?: {
insights, insights,
htmlPath, htmlPath,
data: aggregated, data: aggregated,
remoteStats,
facets: substantiveFacets, facets: substantiveFacets,
} }
} }
@@ -3043,31 +2840,8 @@ const usageReport: Command = {
contentLength: 0, // Dynamic content contentLength: 0, // Dynamic content
progressMessage: 'analyzing your sessions', progressMessage: 'analyzing your sessions',
source: 'builtin', source: 'builtin',
async getPromptForCommand(args) { async getPromptForCommand(_args) {
let collectRemote = false const { insights, htmlPath, data } = await generateUsageReport()
let remoteHosts: string[] = []
let hasRemoteHosts = false
if (process.env.USER_TYPE === 'ant') {
// Parse --homespaces flag
collectRemote = args?.includes('--homespaces') ?? false
// Check for available remote hosts
remoteHosts = await getRunningRemoteHosts()
hasRemoteHosts = remoteHosts.length > 0
// Show collection message if collecting
if (collectRemote && hasRemoteHosts) {
// biome-ignore lint/suspicious/noConsole: intentional
console.error(
`Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
)
}
}
const { insights, htmlPath, data, remoteStats } = await generateUsageReport(
{ collectRemote },
)
let reportUrl = `file://${htmlPath}` let reportUrl = `file://${htmlPath}`
let uploadHint = '' let uploadHint = ''
@@ -3085,20 +2859,6 @@ const usageReport: Command = {
`${data.git_commits} commits`, `${data.git_commits} commits`,
].join(' · ') ].join(' · ')
// Build remote host info (internal-only)
let remoteInfo = ''
if (process.env.USER_TYPE === 'ant') {
if (remoteStats && remoteStats.totalCopied > 0) {
const hsNames = remoteStats.hosts
.filter(h => h.sessionCount > 0)
.map(h => h.name)
.join(', ')
remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n`
} else if (!collectRemote && hasRemoteHosts) {
// Suggest using --homespaces if they have remote hosts but didn't use the flag
remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n`
}
}
// Build markdown summary from insights // Build markdown summary from insights
const atAGlance = insights.at_a_glance const atAGlance = insights.at_a_glance
@@ -3118,7 +2878,6 @@ ${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitiou
${stats} ${stats}
${data.date_range.start} to ${data.date_range.end} ${data.date_range.start} to ${data.date_range.end}
${remoteInfo}
` `
const userSummary = `${header}${summaryText} const userSummary = `${header}${summaryText}

View File

@@ -1,20 +1,28 @@
import { PassThrough } from 'node:stream' import { PassThrough } from 'node:stream'
import { expect, test } from 'bun:test' import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react' import React from 'react'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { createRoot, render, useApp } from '../../ink.js' import { createRoot, render, useApp } from '../../ink.js'
import { AppStateProvider } from '../../state/AppState.js' import { AppStateProvider } from '../../state/AppState.js'
import { import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
buildCurrentProviderSummary, buildCurrentProviderSummary,
buildProfileSaveMessage, buildProfileSaveMessage,
getProviderWizardDefaults, getProviderWizardDefaults,
ProviderWizard,
TextEntryDialog, TextEntryDialog,
} from './provider.js' } from './provider.js'
import { createProfileFile } from '../../utils/providerProfile.js'
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l' const SYNC_END = '\x1B[?2026l'
const ORIGINAL_SIMPLE_ENV = process.env.CLAUDE_CODE_SIMPLE
const ORIGINAL_CODEX_API_KEY = process.env.CODEX_API_KEY
const ORIGINAL_CHATGPT_ACCOUNT_ID = process.env.CHATGPT_ACCOUNT_ID
const ORIGINAL_CODEX_ACCOUNT_ID = process.env.CODEX_ACCOUNT_ID
function extractLastFrame(output: string): string { function extractLastFrame(output: string): string {
let lastFrame: string | null = null let lastFrame: string | null = null
@@ -60,6 +68,51 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
return stripAnsi(extractLastFrame(getOutput())) return stripAnsi(extractLastFrame(getOutput()))
} }
async function waitForOutput(
getOutput: () => string,
predicate: (output: string) => boolean,
timeoutMs = 2500,
): Promise<string> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const output = stripAnsi(extractLastFrame(getOutput()))
if (predicate(output)) {
return output
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for ProviderWizard test output')
}
async function renderProviderWizardFrame(): Promise<string> {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(
<AppStateProvider>
<ProviderWizard onDone={() => {}} />
</AppStateProvider>,
)
try {
return await waitForOutput(
getOutput,
output => output.includes('Set up a provider profile'),
)
} finally {
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
}
}
function createTestStreams(): { function createTestStreams(): {
stdout: PassThrough stdout: PassThrough
stdin: PassThrough & { stdin: PassThrough & {
@@ -94,6 +147,34 @@ function createTestStreams(): {
} }
} }
afterEach(() => {
mock.restore()
if (ORIGINAL_SIMPLE_ENV === undefined) {
delete process.env.CLAUDE_CODE_SIMPLE
} else {
process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV
}
if (ORIGINAL_CODEX_API_KEY === undefined) {
delete process.env.CODEX_API_KEY
} else {
process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY
}
if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) {
delete process.env.CHATGPT_ACCOUNT_ID
} else {
process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID
}
if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) {
delete process.env.CODEX_ACCOUNT_ID
} else {
process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID
}
})
function StepChangeHarness(): React.ReactNode { function StepChangeHarness(): React.ReactNode {
const { exit } = useApp() const { exit } = useApp()
const [step, setStep] = React.useState<'api' | 'model'>('api') const [step, setStep] = React.useState<'api' | 'model'>('api')
@@ -233,6 +314,167 @@ test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly',
expect(message).not.toContain('AIza') expect(message).not.toContain('AIza')
}) })
test('buildProfileSaveMessage reflects immediate Codex activation for existing credentials', () => {
const message = buildProfileSaveMessage(
'codex',
{
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_codex',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
{
activatedInSession: true,
},
)
expect(message).toContain('Saved Codex profile.')
expect(message).toContain('OpenClaude switched to it for this session.')
expect(message).not.toContain('Restart OpenClaude to use it.')
})
test('buildProfileSaveMessage reflects immediate Codex OAuth activation when the session switched successfully', () => {
const message = buildProfileSaveMessage(
'codex',
{
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_codex',
CODEX_CREDENTIAL_SOURCE: 'oauth',
},
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
{
activatedInSession: true,
},
)
expect(message).toContain('Saved Codex profile.')
expect(message).toContain('OpenClaude switched to it for this session.')
expect(message).not.toContain('Restart OpenClaude to use it.')
})
test('buildCodexOAuthProfileEnv uses the fresh OAuth account id without persisting an API key', () => {
process.env.CODEX_API_KEY = 'stale-codex-key'
process.env.CHATGPT_ACCOUNT_ID = 'acct_stale'
const env = buildCodexOAuthProfileEnv({
accessToken: 'oauth-access-token',
accountId: 'acct_oauth',
})
expect(env).toEqual({
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
expect(env).not.toHaveProperty('CODEX_API_KEY')
})
test('buildCodexProfileEnv derives oauth source from secure storage when no explicit source is provided', async () => {
const actualProviderConfig = await import('../../services/api/providerConfig.js')
mock.module('../../services/api/providerConfig.js', () => ({
...actualProviderConfig,
resolveCodexApiCredentials: () => ({
apiKey: 'stored-access-token',
accountId: 'acct_secure_storage',
source: 'secure-storage' as const,
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { buildCodexProfileEnv } = await import(
'../../utils/providerProfile.js?secure-storage-codex-source'
)
const env = buildCodexProfileEnv({
model: 'codexplan',
processEnv: {},
})
expect(env).toEqual({
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: 'acct_secure_storage',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
})
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex'
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
OPENAI_API_KEY: 'sk-openai',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_codex',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'provider_old',
}
const profileFile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_codex',
})
const warning = await applySavedProfileToCurrentSession({
profileFile,
processEnv,
})
expect(warning).toBeNull()
expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeUndefined()
expect(processEnv.OPENAI_API_KEY).toBe("sk-openai")
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
)
const processEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
CODEX_API_KEY: 'stale-codex-key',
CHATGPT_ACCOUNT_ID: 'acct_stale',
}
const profileFile = createProfileFile('codex', {
OPENAI_MODEL: 'codexplan',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CHATGPT_ACCOUNT_ID: 'acct_oauth',
CODEX_CREDENTIAL_SOURCE: 'oauth',
})
const warning = await applySavedProfileToCurrentSession({
profileFile,
processEnv,
})
expect(warning).not.toBeUndefined()
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBe("stale-codex-key")
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_stale')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
})
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => { test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({ const summary = buildCurrentProviderSummary({
processEnv: { processEnv: {
@@ -245,8 +487,8 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', (
}) })
expect(summary.providerLabel).toBe('OpenAI-compatible') expect(summary.providerLabel).toBe('OpenAI-compatible')
expect(summary.modelLabel).toBe('sk-...5678') expect(summary.modelLabel).toBe('sk-...678')
expect(summary.endpointLabel).toBe('sk-...5678') expect(summary.endpointLabel).toBe('sk-...678')
}) })
test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => { test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => {
@@ -264,7 +506,7 @@ test('buildCurrentProviderSummary labels generic local openai-compatible provide
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1') expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1')
}) })
test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex', () => { test('buildCurrentProviderSummary does not relabel local gpt-5.4 providers as Codex when custom base URL is set', () => {
const summary = buildCurrentProviderSummary({ const summary = buildCurrentProviderSummary({
processEnv: { processEnv: {
CLAUDE_CODE_USE_OPENAI: '1', CLAUDE_CODE_USE_OPENAI: '1',
@@ -307,3 +549,12 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1') expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
expect(defaults.geminiModel).toBe('gemini-2.0-flash') expect(defaults.geminiModel).toBe('gemini-2.0-flash')
}) })
test('ProviderWizard hides Codex OAuth while running in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const output = await renderProviderWizardFrame()
expect(output).toContain('Set up a provider profile')
expect(output).not.toContain('Codex OAuth')
})

View File

@@ -10,8 +10,12 @@ import {
} from '../../components/CustomSelect/index.js' } from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js' import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js' import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js' import { Box, Text } from '../../ink.js'
import {
type CodexOAuthTokens,
} from '../../services/api/codexOAuth.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_BASE_URL,
@@ -20,6 +24,8 @@ import {
resolveProviderRequest, resolveProviderRequest,
} from '../../services/api/providerConfig.js' } from '../../services/api/providerConfig.js'
import { import {
applySavedProfileToCurrentSession as applySharedProfileToCurrentSession,
buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv,
buildCodexProfileEnv, buildCodexProfileEnv,
buildGeminiProfileEnv, buildGeminiProfileEnv,
buildMistralProfileEnv, buildMistralProfileEnv,
@@ -49,6 +55,7 @@ import {
readGeminiAccessToken, readGeminiAccessToken,
saveGeminiAccessToken, saveGeminiAccessToken,
} from '../../utils/geminiCredentials.js' } from '../../utils/geminiCredentials.js'
import { isBareMode } from '../../utils/envUtils.js'
import { import {
getGoalDefaultOpenAIModel, getGoalDefaultOpenAIModel,
normalizeRecommendationGoal, normalizeRecommendationGoal,
@@ -57,12 +64,47 @@ import {
type RecommendationGoal, type RecommendationGoal,
} from '../../utils/providerRecommendation.js' } from '../../utils/providerRecommendation.js'
import { import {
getOllamaChatBaseUrl,
getLocalOpenAICompatibleProviderLabel, getLocalOpenAICompatibleProviderLabel,
hasLocalOllama, probeOllamaGenerationReadiness,
listOllamaModels, type OllamaGenerationReadiness,
} from '../../utils/providerDiscovery.js' } from '../../utils/providerDiscovery.js'
type ProviderChoice = 'auto' | ProviderProfile | 'clear' function describeOllamaReadinessIssue(
readiness: OllamaGenerationReadiness,
options?: {
baseUrl?: string
allowManualFallback?: boolean
},
): string {
const endpoint = options?.baseUrl ?? 'http://localhost:11434'
if (readiness.state === 'unreachable') {
return `Could not reach Ollama at ${endpoint}. Start Ollama first, then run /provider again.`
}
if (readiness.state === 'no_models') {
const manualSuffix = options?.allowManualFallback
? ', or enter details manually'
: ''
return `Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first${manualSuffix}.`
}
if (readiness.state === 'generation_failed') {
const modelHint = readiness.probeModel ?? 'the selected model'
const detailSuffix = readiness.detail
? ` Details: ${readiness.detail}.`
: ''
const manualSuffix = options?.allowManualFallback
? ' You can also enter details manually.'
: ''
return `Ollama is reachable and models are installed, but a generation probe failed for ${modelHint}.${detailSuffix} Run "ollama run ${modelHint}" once and retry.${manualSuffix}`
}
return ''
}
type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
type Step = type Step =
| { name: 'choose' } | { name: 'choose' }
@@ -93,6 +135,7 @@ type Step =
apiKey?: string apiKey?: string
authMode: 'api-key' | 'access-token' | 'adc' authMode: 'api-key' | 'access-token' | 'adc'
} }
| { name: 'codex-oauth' }
| { name: 'codex-check' } | { name: 'codex-check' }
type CurrentProviderSummary = { type CurrentProviderSummary = {
@@ -131,6 +174,8 @@ type ProviderWizardDefaults = {
mistralBaseUrl: string mistralBaseUrl: string
} }
type SecretSourceEnv = NodeJS.ProcessEnv & Partial<ProfileEnv>
function isEnvTruthy(value: string | undefined): boolean { function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false if (!value) return false
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
@@ -139,7 +184,7 @@ function isEnvTruthy(value: string | undefined): boolean {
function getSafeDisplayValue( function getSafeDisplayValue(
value: string | undefined, value: string | undefined,
processEnv: NodeJS.ProcessEnv, processEnv: SecretSourceEnv,
profileEnv?: ProfileEnv, profileEnv?: ProfileEnv,
fallback = '(not set)', fallback = '(not set)',
): string { ): string {
@@ -151,14 +196,15 @@ function getSafeDisplayValue(
export function getProviderWizardDefaults( export function getProviderWizardDefaults(
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): ProviderWizardDefaults { ): ProviderWizardDefaults {
const secretSource = processEnv as SecretSourceEnv
const safeOpenAIModel = const safeOpenAIModel =
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
'gpt-4o' 'gpt-4o'
const safeOpenAIBaseUrl = const safeOpenAIBaseUrl =
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) || sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
DEFAULT_OPENAI_BASE_URL DEFAULT_OPENAI_BASE_URL
const safeGeminiModel = const safeGeminiModel =
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
DEFAULT_GEMINI_MODEL DEFAULT_GEMINI_MODEL
const safeMistralModel = const safeMistralModel =
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
@@ -181,6 +227,7 @@ export function buildCurrentProviderSummary(options?: {
persisted?: ProfileFile | null persisted?: ProfileFile | null
}): CurrentProviderSummary { }): CurrentProviderSummary {
const processEnv = options?.processEnv ?? process.env const processEnv = options?.processEnv ?? process.env
const secretSource = processEnv as SecretSourceEnv
const persisted = options?.persisted ?? loadProfileFile() const persisted = options?.persisted ?? loadProfileFile()
const savedProfileLabel = persisted?.profile ?? 'none' const savedProfileLabel = persisted?.profile ?? 'none'
@@ -189,11 +236,11 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'Google Gemini', providerLabel: 'Google Gemini',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL, processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL, processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -219,13 +266,13 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'GitHub Models', providerLabel: 'GitHub Models',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
processEnv.OPENAI_MODEL ?? 'github:copilot', processEnv.OPENAI_MODEL ?? 'github:copilot',
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_BASE_URL ??
processEnv.OPENAI_API_BASE ?? processEnv.OPENAI_API_BASE ??
'https://models.github.ai/inference', 'https://models.github.ai/inference',
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -246,8 +293,8 @@ export function buildCurrentProviderSummary(options?: {
return { return {
providerLabel, providerLabel,
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv), modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv), endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
savedProfileLabel, savedProfileLabel,
} }
} }
@@ -258,11 +305,11 @@ export function buildCurrentProviderSummary(options?: {
processEnv.ANTHROPIC_MODEL ?? processEnv.ANTHROPIC_MODEL ??
processEnv.CLAUDE_MODEL ?? processEnv.CLAUDE_MODEL ??
'claude-sonnet-4-6', 'claude-sonnet-4-6',
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com', processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -376,6 +423,10 @@ export function buildProfileSaveMessage(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, env: ProfileEnv,
filePath: string, filePath: string,
options?: {
activatedInSession?: boolean
activationWarning?: string | null
},
): string { ): string {
const summary = buildSavedProfileSummary(profile, env) const summary = buildSavedProfileSummary(profile, env)
const lines = [ const lines = [
@@ -389,13 +440,24 @@ export function buildProfileSaveMessage(
} }
lines.push(`Profile: ${filePath}`) lines.push(`Profile: ${filePath}`)
if (options?.activatedInSession) {
lines.push('OpenClaude switched to it for this session.')
} else if (options?.activationWarning) {
lines.push(
`Saved for next startup. Warning: could not activate it in this session (${options.activationWarning}).`,
)
} else {
lines.push('Restart OpenClaude to use it.') lines.push('Restart OpenClaude to use it.')
}
return lines.join('\n') return lines.join('\n')
} }
function buildUsageText(): string { function buildUsageText(): string {
const summary = buildCurrentProviderSummary() const summary = buildCurrentProviderSummary()
const availableProviders = isBareMode()
? 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a provider profile.'
: 'Choose Auto, Ollama, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.'
return [ return [
'Usage: /provider', 'Usage: /provider',
'', '',
@@ -406,7 +468,7 @@ function buildUsageText(): string {
`Current endpoint: ${summary.endpointLabel}`, `Current endpoint: ${summary.endpointLabel}`,
`Saved profile: ${summary.savedProfileLabel}`, `Saved profile: ${summary.savedProfileLabel}`,
'', '',
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.', availableProviders,
].join('\n') ].join('\n')
} }
@@ -415,12 +477,45 @@ function finishProfileSave(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, env: ProfileEnv,
): void { ): void {
void saveProfileAndNotify(onDone, profile, env)
}
export function buildCodexOAuthProfileEnv(
tokens: Pick<CodexOAuthTokens, 'accessToken' | 'idToken' | 'accountId'>,
): ProfileEnv | null {
return buildSharedCodexOAuthProfileEnv(tokens)
}
export async function applySavedProfileToCurrentSession(options: {
profileFile: ProfileFile
processEnv?: NodeJS.ProcessEnv
}): Promise<string | null> {
return applySharedProfileToCurrentSession(options)
}
async function saveProfileAndNotify(
onDone: LocalJSXCommandOnDone,
profile: ProviderProfile,
env: ProfileEnv,
): Promise<void> {
try { try {
const profileFile = createProfileFile(profile, env) const profileFile = createProfileFile(profile, env)
const filePath = saveProfileFile(profileFile) const filePath = saveProfileFile(profileFile)
onDone(buildProfileSaveMessage(profile, env, filePath), { const shouldActivateInSession = profile === 'codex'
const activationWarning = shouldActivateInSession
? await applySharedProfileToCurrentSession({ profileFile })
: null
onDone(
buildProfileSaveMessage(profile, env, filePath, {
activatedInSession:
shouldActivateInSession && activationWarning === null,
activationWarning,
}),
{
display: 'system', display: 'system',
}) },
)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
onDone(`Failed to save provider profile: ${message}`, { onDone(`Failed to save provider profile: ${message}`, {
@@ -504,6 +599,10 @@ function ProviderChooser({
onCancel: () => void onCancel: () => void
}): React.ReactNode { }): React.ReactNode {
const summary = buildCurrentProviderSummary() const summary = buildCurrentProviderSummary()
const canUseCodexOAuth = !isBareMode()
const helperText = canUseCodexOAuth
? 'Save a provider profile without editing environment variables first. Codex profiles backed by env, auth.json, or OpenClaude secure storage can switch this session immediately when validation succeeds.'
: 'Save a provider profile without editing environment variables first. Codex profiles backed by env or auth.json can switch this session immediately.'
const options: OptionWithDescription<ProviderChoice>[] = [ const options: OptionWithDescription<ProviderChoice>[] = [
{ {
label: 'Auto', label: 'Auto',
@@ -537,6 +636,16 @@ function ProviderChooser({
value: 'codex', value: 'codex',
description: 'Use existing ChatGPT Codex CLI auth or env credentials', description: 'Use existing ChatGPT Codex CLI auth or env credentials',
}, },
...(canUseCodexOAuth
? [
{
label: 'Codex OAuth',
value: 'codex-oauth' as const,
description:
'Sign in with ChatGPT in your browser and store Codex tokens securely',
},
]
: []),
] ]
if (summary.savedProfileLabel !== 'none') { if (summary.savedProfileLabel !== 'none') {
@@ -554,10 +663,7 @@ function ProviderChooser({
onCancel={onCancel} onCancel={onCancel}
> >
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text> <Text>{helperText}</Text>
Save a provider profile for the next OpenClaude restart without
editing environment variables first.
</Text>
<Box flexDirection="column"> <Box flexDirection="column">
<Text dimColor>Current model: {summary.modelLabel}</Text> <Text dimColor>Current model: {summary.modelLabel}</Text>
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text> <Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
@@ -643,6 +749,7 @@ function AutoRecommendationStep({
| { | {
state: 'openai' state: 'openai'
defaultModel: string defaultModel: string
reason: string
} }
| { | {
state: 'error' state: 'error'
@@ -656,19 +763,27 @@ function AutoRecommendationStep({
void (async () => { void (async () => {
const defaultModel = getGoalDefaultOpenAIModel(goal) const defaultModel = getGoalDefaultOpenAIModel(goal)
try { try {
const ollamaAvailable = await hasLocalOllama() const readiness = await probeOllamaGenerationReadiness()
if (!ollamaAvailable) { if (readiness.state !== 'ready') {
if (!cancelled) { if (!cancelled) {
setStatus({ state: 'openai', defaultModel }) setStatus({
state: 'openai',
defaultModel,
reason: describeOllamaReadinessIssue(readiness),
})
} }
return return
} }
const models = await listOllamaModels() const recommended = recommendOllamaModel(readiness.models, goal)
const recommended = recommendOllamaModel(models, goal)
if (!recommended) { if (!recommended) {
if (!cancelled) { if (!cancelled) {
setStatus({ state: 'openai', defaultModel }) setStatus({
state: 'openai',
defaultModel,
reason:
'Ollama responded to a generation probe, but no recommended chat model matched this goal.',
})
} }
return return
} }
@@ -709,7 +824,9 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -722,17 +839,17 @@ function AutoRecommendationStep({
<Dialog title="Auto setup fallback" onCancel={onCancel}> <Dialog title="Auto setup fallback" onCancel={onCancel}>
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text> <Text>
No viable local Ollama chat model was detected. Auto setup can Auto setup can continue into OpenAI-compatible setup with a default model of{' '}
continue into OpenAI-compatible setup with a default model of{' '}
{status.defaultModel}. {status.defaultModel}.
</Text> </Text>
<Text dimColor>{status.reason}</Text>
<Select <Select
options={[ options={[
{ label: 'Continue to OpenAI-compatible setup', value: 'continue' }, { label: 'Continue to OpenAI-compatible setup', value: 'continue' },
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'continue') { if (value === 'continue') {
onNeedOpenAI(status.defaultModel) onNeedOpenAI(status.defaultModel)
} else if (value === 'back') { } else if (value === 'back') {
@@ -765,7 +882,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'save') { if (value === 'save') {
onSave( onSave(
'ollama', 'ollama',
@@ -809,32 +926,19 @@ function OllamaModelStep({
let cancelled = false let cancelled = false
void (async () => { void (async () => {
const available = await hasLocalOllama() const readiness = await probeOllamaGenerationReadiness()
if (!available) { if (readiness.state !== 'ready') {
if (!cancelled) { if (!cancelled) {
setStatus({ setStatus({
state: 'unavailable', state: 'unavailable',
message: message: describeOllamaReadinessIssue(readiness),
'Could not reach Ollama at http://localhost:11434. Start Ollama first, then run /provider again.',
}) })
} }
return return
} }
const models = await listOllamaModels() const ranked = rankOllamaModels(readiness.models, 'balanced')
if (models.length === 0) { const recommended = recommendOllamaModel(readiness.models, 'balanced')
if (!cancelled) {
setStatus({
state: 'unavailable',
message:
'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first.',
})
}
return
}
const ranked = rankOllamaModels(models, 'balanced')
const recommended = recommendOllamaModel(models, 'balanced')
if (!cancelled) { if (!cancelled) {
setStatus({ setStatus({
state: 'ready', state: 'ready',
@@ -867,7 +971,9 @@ function OllamaModelStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -888,7 +994,7 @@ function OllamaModelStep({
defaultFocusValue={status.defaultValue} defaultFocusValue={status.defaultValue}
inlineDescriptions inlineDescriptions
visibleOptionCount={Math.min(8, status.options.length)} visibleOptionCount={Math.min(8, status.options.length)}
onChange={value => { onChange={(value: string) => {
onSave( onSave(
'ollama', 'ollama',
buildOllamaProfileEnv(value, { buildOllamaProfileEnv(value, {
@@ -903,6 +1009,84 @@ function OllamaModelStep({
) )
} }
function CodexOAuthStep({
onSave,
onBack,
onCancel,
}: {
onSave: (profile: ProviderProfile, env: ProfileEnv) => void
onBack: () => void
onCancel: () => void
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (
tokens: CodexOAuthTokens,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
const env = buildCodexOAuthProfileEnv(tokens)
if (!env) {
throw new Error(
'Codex OAuth succeeded, but OpenClaude could not build a Codex profile from the stored credentials.',
)
}
persistCredentials()
onSave('codex', env)
}, [onSave])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Dialog title="Codex OAuth failed" onCancel={onCancel} color="warning">
<Box flexDirection="column" gap={1}>
<Text>{status.message}</Text>
<Select
options={[
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
</Dialog>
)
}
if (status.state === 'starting') {
return <LoadingState message="Starting Codex OAuth..." />
}
return (
<Dialog title="Codex OAuth" onCancel={onBack}>
<Box flexDirection="column" gap={1}>
<Text>
Finish signing in with ChatGPT in your browser. OpenClaude will store
the resulting Codex credentials securely for future sessions.
</Text>
{status.browserOpened === false ? (
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
) : status.browserOpened === true ? (
<Text dimColor>
Browser opened. Complete the sign-in there, then OpenClaude will
finish setup automatically.
</Text>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text>{status.authUrl}</Text>
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
</Dialog>
)
}
function CodexCredentialStep({ function CodexCredentialStep({
onSave, onSave,
onBack, onBack,
@@ -924,7 +1108,9 @@ function CodexCredentialStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -958,9 +1144,10 @@ function CodexCredentialStep({
defaultFocusValue="codexplan" defaultFocusValue="codexplan"
inlineDescriptions inlineDescriptions
visibleOptionCount={options.length} visibleOptionCount={options.length}
onChange={value => { onChange={(value: string) => {
const env = buildCodexProfileEnv({ const env = buildCodexProfileEnv({
model: value, model: value,
credentialSource: credentials.credentialSource,
processEnv: process.env, processEnv: process.env,
}) })
if (env) { if (env) {
@@ -975,9 +1162,16 @@ function CodexCredentialStep({
} }
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv): function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
| { ok: true; sourceDescription: string } | {
ok: true
sourceDescription: string
credentialSource: 'oauth' | 'existing'
}
| { ok: false; message: string } { | { ok: false; message: string } {
const credentials = resolveCodexApiCredentials(processEnv) const credentials = resolveCodexApiCredentials(processEnv)
const oauthHint = isBareMode()
? 'Re-login with the Codex CLI'
: 'Choose Codex OAuth in /provider, or re-login with the Codex CLI'
if (!credentials.apiKey) { if (!credentials.apiKey) {
const authHint = credentials.authPath const authHint = credentials.authPath
@@ -985,7 +1179,7 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
: 'Set CODEX_API_KEY or re-login with the Codex CLI.' : 'Set CODEX_API_KEY or re-login with the Codex CLI.'
return { return {
ok: false, ok: false,
message: `Codex setup needs existing credentials. Re-login with the Codex CLI or set CODEX_API_KEY. ${authHint}`, message: `Codex setup needs existing credentials. ${oauthHint}, or set CODEX_API_KEY. ${authHint}`,
} }
} }
@@ -993,15 +1187,19 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
return { return {
ok: false, ok: false,
message: message:
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.', `Codex auth is missing chatgpt_account_id. ${oauthHint}, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.`,
} }
} }
return { return {
ok: true, ok: true,
credentialSource:
credentials.source === 'secure-storage' ? 'oauth' : 'existing',
sourceDescription: sourceDescription:
credentials.source === 'env' credentials.source === 'env'
? 'the current shell environment' ? 'the current shell environment'
: credentials.source === 'secure-storage'
? 'OpenClaude secure storage'
: credentials.authPath ?? DEFAULT_CODEX_BASE_URL, : credentials.authPath ?? DEFAULT_CODEX_BASE_URL,
} }
} }
@@ -1035,6 +1233,8 @@ export function ProviderWizard({
name: 'mistral-key', name: 'mistral-key',
defaultModel: defaults.mistralModel, defaultModel: defaults.mistralModel,
}) })
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
} else if (value === 'clear') { } else if (value === 'clear') {
const filePath = deleteProfileFile() const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, { onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1314,7 +1514,7 @@ export function ProviderWizard({
options={options} options={options}
inlineDescriptions inlineDescriptions
visibleOptionCount={options.length} visibleOptionCount={options.length}
onChange={value => { onChange={(value: string) => {
if (value === 'api-key') { if (value === 'api-key') {
setStep({ name: 'gemini-key' }) setStep({ name: 'gemini-key' })
} else if (value === 'access-token') { } else if (value === 'access-token') {
@@ -1470,6 +1670,15 @@ export function ProviderWizard({
onCancel={() => onDone()} onCancel={() => onDone()}
/> />
) )
case 'codex-oauth':
return (
<CodexOAuthStep
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
onBack={() => setStep({ name: 'choose' })}
onCancel={() => onDone()}
/>
)
} }
} }

View File

@@ -101,7 +101,7 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
<Box marginBottom={1} flexDirection="column"> <Box marginBottom={1} flexDirection="column">
<Text color="remember" bold={true}>Set effort level</Text> <Text color="remember" bold={true}>Set effort level</Text>
<Text dimColor={true}> <Text dimColor={true}>
{usesOpenAIEffort {supportsEffort && usesOpenAIEffort
? `OpenAI/Codex provider (${provider})` ? `OpenAI/Codex provider (${provider})`
: supportsEffort : supportsEffort
? `Claude model · ${provider} provider` ? `Claude model · ${provider} provider`

View File

@@ -5,13 +5,14 @@ import React from 'react'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js' import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../state/AppState.js'
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l' const SYNC_END = '\x1B[?2026l'
const ORIGINAL_ENV = { const ORIGINAL_ENV = {
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN, GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN, GH_TOKEN: process.env.GH_TOKEN,
@@ -109,6 +110,9 @@ function createDeferred<T>(): {
function mockProviderProfilesModule(options?: { function mockProviderProfilesModule(options?: {
addProviderProfile?: (...args: unknown[]) => unknown addProviderProfile?: (...args: unknown[]) => unknown
getProviderProfiles?: () => unknown[]
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
}): void { }): void {
mock.module('../utils/providerProfiles.js', () => ({ mock.module('../utils/providerProfiles.js', () => ({
addProviderProfile: options?.addProviderProfile ?? (() => null), addProviderProfile: options?.addProviderProfile ?? (() => null),
@@ -131,48 +135,135 @@ function mockProviderProfilesModule(options?: {
model: 'mock-model', model: 'mock-model',
apiKey: '', apiKey: '',
}, },
getProviderProfiles: () => [], getProviderProfiles: options?.getProviderProfiles ?? (() => []),
setActiveProviderProfile: () => null, setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
updateProviderProfile: () => null, updateProviderProfile: options?.updateProviderProfile ?? (() => null),
})) }))
} }
function mockProviderManagerDependencies( function mockProviderManagerDependencies(
syncRead: () => string | undefined, githubSyncRead: () => string | undefined,
asyncRead: () => Promise<string | undefined>, githubAsyncRead: () => Promise<string | undefined>,
options?: { options?: {
addProviderProfile?: (...args: unknown[]) => unknown addProviderProfile?: (...args: unknown[]) => unknown
hasLocalOllama?: () => Promise<boolean> applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
listOllamaModels?: () => Promise< clearCodexCredentials?: () => { success: boolean; warning?: string }
Array<{ getProviderProfiles?: () => unknown[]
probeOllamaGenerationReadiness?: () => Promise<{
state: 'ready' | 'unreachable' | 'no_models' | 'generation_failed'
models: Array<
{
name: string name: string
sizeBytes?: number | null sizeBytes?: number | null
family?: string | null family?: string | null
families?: string[] families?: string[]
parameterSize?: string | null parameterSize?: string | null
quantizationLevel?: string | null quantizationLevel?: string | null
}> }
> >
probeModel?: string
detail?: string
}>
codexSyncRead?: () => unknown
codexAsyncRead?: () => Promise<unknown>
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
useCodexOAuthFlow?: (options: {
onAuthenticated: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) =>
void | Promise<void>
}) => {
state: 'starting' | 'waiting' | 'error'
authUrl?: string
browserOpened?: boolean | null
message?: string
}
}, },
): void { ): void {
mockProviderProfilesModule({ addProviderProfile: options?.addProviderProfile }) mockProviderProfilesModule({
addProviderProfile: options?.addProviderProfile,
getProviderProfiles: options?.getProviderProfiles,
updateProviderProfile: options?.updateProviderProfile,
setActiveProviderProfile: options?.setActiveProviderProfile,
})
mock.module('../utils/providerDiscovery.js', () => ({ mock.module('../utils/providerDiscovery.js', () => ({
hasLocalOllama: options?.hasLocalOllama ?? (async () => false), probeOllamaGenerationReadiness:
listOllamaModels: options?.listOllamaModels ?? (async () => []), options?.probeOllamaGenerationReadiness ??
(async () => ({
state: 'unreachable' as const,
models: [],
})),
})) }))
mock.module('../utils/githubModelsCredentials.js', () => ({ mock.module('../utils/githubModelsCredentials.js', () => ({
clearGithubModelsToken: () => ({ success: true }), clearGithubModelsToken: () => ({ success: true }),
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED', GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
hydrateGithubModelsTokenFromSecureStorage: () => {}, hydrateGithubModelsTokenFromSecureStorage: () => {},
readGithubModelsToken: syncRead, readGithubModelsToken: githubSyncRead,
readGithubModelsTokenAsync: asyncRead, readGithubModelsTokenAsync: githubAsyncRead,
}))
mock.module('../utils/codexCredentials.js', () => ({
attachCodexProfileIdToStoredCredentials: () => ({ success: true }),
clearCodexCredentials:
options?.clearCodexCredentials ?? (() => ({ success: true })),
readCodexCredentials:
options?.codexSyncRead ?? (() => undefined),
readCodexCredentialsAsync:
options?.codexAsyncRead ?? (async () => undefined),
}))
mock.module('../utils/providerProfile.js', () => ({
applySavedProfileToCurrentSession:
options?.applySavedProfileToCurrentSession ?? (async () => null),
buildCodexOAuthProfileEnv: (tokens: {
accessToken: string
accountId?: string
idToken?: string
}) => {
const accountId =
tokens.accountId ??
(tokens.idToken ? 'acct_from_id_token' : undefined) ??
(tokens.accessToken ? 'acct_from_access_token' : undefined)
if (!accountId) {
return null
}
return {
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: accountId,
CODEX_CREDENTIAL_SOURCE: 'oauth' as const,
}
},
clearPersistedCodexOAuthProfile: () => null,
createProfileFile: (profile: string, env: Record<string, unknown>) => ({
profile,
env,
createdAt: '2026-04-10T00:00:00.000Z',
}),
})) }))
mock.module('../utils/settings/settings.js', () => ({ mock.module('../utils/settings/settings.js', () => ({
updateSettingsForSource: () => ({ error: null }), updateSettingsForSource: () => ({ error: null }),
})) }))
mock.module('./useCodexOAuthFlow.js', () => ({
useCodexOAuthFlow:
options?.useCodexOAuthFlow ??
(() => ({
state: 'waiting' as const,
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
})),
}))
} }
async function waitForFrameOutput( async function waitForFrameOutput(
@@ -240,9 +331,9 @@ async function renderProviderManagerFrame(
onDone: (result?: unknown) => void onDone: (result?: unknown) => void
}>, }>,
options?: { options?: {
mode?: 'first-run' | 'manage'
waitForOutput?: (output: string) => boolean waitForOutput?: (output: string) => boolean
timeoutMs?: number timeoutMs?: number
mode?: 'first-run' | 'manage'
}, },
): Promise<string> { ): Promise<string> {
const mounted = await mountProviderManager(ProviderManager, { const mounted = await mountProviderManager(ProviderManager, {
@@ -305,6 +396,47 @@ test('ProviderManager resolves GitHub virtual provider from async storage withou
expect(asyncRead).toHaveBeenCalled() expect(asyncRead).toHaveBeenCalled()
}) })
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const syncRead = mock(() => {
throw new Error('sync credential read should not run in ProviderManager render flow')
})
const deferredStoredToken = createDeferred<string | undefined>()
const asyncRead = mock(async () => deferredStoredToken.promise)
mockProviderManagerDependencies(syncRead, asyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Provider manager'),
)
expect(firstFrame).toContain('Checking GitHub Models credentials...')
expect(firstFrame).not.toContain('No provider profiles configured yet.')
deferredStoredToken.resolve('stored-token')
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
)
expect(resolvedFrame).toContain('GitHub Models')
expect(resolvedFrame).toContain('token stored')
await mounted.dispose()
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})
test('ProviderManager first-run Ollama preset auto-detects installed models', async () => { test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN delete process.env.GITHUB_TOKEN
@@ -331,8 +463,9 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
async () => undefined, async () => undefined,
{ {
addProviderProfile, addProviderProfile,
hasLocalOllama: async () => true, probeOllamaGenerationReadiness: async () => ({
listOllamaModels: async () => [ state: 'ready',
models: [
{ {
name: 'gemma4:31b-cloud', name: 'gemma4:31b-cloud',
family: 'gemma', family: 'gemma',
@@ -344,6 +477,8 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
parameterSize: '2.5b', parameterSize: '2.5b',
}, },
], ],
probeModel: 'gemma4:31b-cloud',
}),
}, },
) )
@@ -395,43 +530,411 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
await mounted.dispose() await mounted.dispose()
}) })
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => { test('ProviderManager first-run Codex OAuth switches the current session after login completes', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN delete process.env.GH_TOKEN
const syncRead = mock(() => { const onDone = mock(() => {})
throw new Error('sync credential read should not run in ProviderManager render flow') const applySavedProfileToCurrentSession = mock(async () => null)
}) const persistCredentials = mock(() => {})
const deferredStoredToken = createDeferred<string | undefined>() const addProviderProfile = mock((payload: {
const asyncRead = mock(async () => deferredStoredToken.promise) provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(syncRead, asyncRead) mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
applySavedProfileToCurrentSession,
useCodexOAuthFlow: ({ onAuthenticated }) => {
React.useEffect(() => {
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}),
expect.objectContaining({ makeActive: true }),
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message:
'Codex OAuth configured. OpenClaude switched to it for this session.',
}),
)
await mounted.dispose()
})
test('ProviderManager first-run Codex OAuth reports next-startup fallback when session activation fails', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const applySavedProfileToCurrentSession = mock(
async () => 'validation failed',
)
const persistCredentials = mock(() => {})
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
applySavedProfileToCurrentSession,
useCodexOAuthFlow: ({ onAuthenticated }) => {
React.useEffect(() => {
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message:
'Codex OAuth configured. Saved for next startup. Warning: validation failed.',
}),
)
await mounted.dispose()
})
test('ProviderManager does not hijack a manual Codex profile when OAuth credentials are not yet linked', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const manualProfile = {
id: 'provider_manual_codex',
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'gpt-5.4',
apiKey: 'manual-key',
}
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
const updateProviderProfile = mock(() => manualProfile)
const persistCredentials = mock(() => {})
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
getProviderProfiles: () => [manualProfile],
updateProviderProfile,
useCodexOAuthFlow: ({ onAuthenticated }) => {
const hasAuthenticated = React.useRef(false)
React.useEffect(() => {
if (hasAuthenticated.current) {
return
}
hasAuthenticated.current = true
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledTimes(1)
expect(updateProviderProfile).not.toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
await mounted.dispose()
})
test('ProviderManager keeps Codex OAuth as next-startup only when activating the session fails from the menu', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const codexProfile = {
id: 'provider_codex_oauth',
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}
const applySavedProfileToCurrentSession = mock(
async () => 'validation failed',
)
const setActiveProviderProfile = mock(() => codexProfile)
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
applySavedProfileToCurrentSession,
getProviderProfiles: () => [codexProfile],
setActiveProviderProfile,
codexAsyncRead: async () => ({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
profileId: 'provider_codex_oauth',
}),
},
)
const nonce = `${Date.now()}-${Math.random()}` const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager) const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput( await waitForFrameOutput(
mounted.getOutput, mounted.getOutput,
frame => frame.includes('Provider manager'), frame =>
frame.includes('Provider manager') &&
frame.includes('Set active provider') &&
frame.includes('Log out Codex OAuth'),
) )
expect(firstFrame).toContain('Checking GitHub Models credentials...') mounted.stdin.write('j')
expect(firstFrame).not.toContain('No provider profiles configured yet.') await Bun.sleep(25)
mounted.stdin.write('\r')
deferredStoredToken.resolve('stored-token') await waitForFrameOutput(
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput, mounted.getOutput,
frame => frame.includes('GitHub Models') && frame.includes('token stored'), frame => frame.includes('Set active provider') && frame.includes('Codex OAuth'),
) )
expect(resolvedFrame).toContain('GitHub Models') await Bun.sleep(25)
expect(resolvedFrame).toContain('token stored') mounted.stdin.write('\r')
await waitForCondition(() => setActiveProviderProfile.mock.calls.length > 0)
await waitForCondition(
() => applySavedProfileToCurrentSession.mock.calls.length > 0,
)
await Bun.sleep(50)
const output = stripAnsi(extractLastFrame(mounted.getOutput()))
expect(output).toContain(
'Active provider: Codex OAuth. Saved for next startup. Warning: validation failed.',
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(setActiveProviderProfile).toHaveBeenCalledWith('provider_codex_oauth')
await mounted.dispose() await mounted.dispose()
})
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled() test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const githubSyncRead = mock(() => undefined)
const githubAsyncRead = mock(async () => undefined)
const codexSyncRead = mock(() => {
throw new Error('sync codex credential read should not run in ProviderManager render flow')
})
const codexAsyncRead = mock(async () => ({
accessToken: 'codex-access-token',
refreshToken: 'codex-refresh-token',
}))
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead, {
codexSyncRead,
codexAsyncRead,
})
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
waitForOutput: frame =>
frame.includes('Provider manager') &&
frame.includes('Log out Codex OAuth'),
})
expect(output).toContain('Provider manager')
expect(output).toContain('Log out Codex OAuth')
expect(codexSyncRead).not.toHaveBeenCalled()
expect(codexAsyncRead).toHaveBeenCalled()
})
test('ProviderManager hides Codex OAuth setup in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const githubSyncRead = mock(() => undefined)
const githubAsyncRead = mock(async () => undefined)
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
mode: 'first-run',
waitForOutput: frame =>
frame.includes('Set up provider') && frame.includes('OpenAI'),
})
expect(output).toContain('Set up provider')
expect(output).not.toContain('Codex OAuth')
}) })

View File

@@ -1,9 +1,22 @@
import figures from 'figures' import figures from 'figures'
import * as React from 'react' import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js' import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js' import { useKeybinding } from '../keybindings/useKeybinding.js'
import { useSetAppState } from '../state/AppState.js'
import type { ProviderProfile } from '../utils/config.js' import type { ProviderProfile } from '../utils/config.js'
import { hasLocalOllama, listOllamaModels } from '../utils/providerDiscovery.js' import {
clearCodexCredentials,
readCodexCredentialsAsync,
} from '../utils/codexCredentials.js'
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
import { getPrimaryModel, hasMultipleModels, parseModelList } from '../utils/providerModels.js'
import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
clearPersistedCodexOAuthProfile,
createProfileFile,
} from '../utils/providerProfile.js'
import { import {
addProviderProfile, addProviderProfile,
applyActiveProviderProfileFromConfig, applyActiveProviderProfileFromConfig,
@@ -16,10 +29,6 @@ import {
type ProviderProfileInput, type ProviderProfileInput,
updateProviderProfile, updateProviderProfile,
} from '../utils/providerProfiles.js' } from '../utils/providerProfiles.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { import {
clearGithubModelsToken, clearGithubModelsToken,
GITHUB_MODELS_HYDRATED_ENV_MARKER, GITHUB_MODELS_HYDRATED_ENV_MARKER,
@@ -27,11 +36,23 @@ import {
readGithubModelsToken, readGithubModelsToken,
readGithubModelsTokenAsync, readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js' } from '../utils/githubModelsCredentials.js'
import { isEnvTruthy } from '../utils/envUtils.js' import {
probeOllamaGenerationReadiness,
type OllamaGenerationReadiness,
} from '../utils/providerDiscovery.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { redactUrlForDisplay } from '../utils/urlRedaction.js'
import { updateSettingsForSource } from '../utils/settings/settings.js' import { updateSettingsForSource } from '../utils/settings/settings.js'
import { type OptionWithDescription, Select } from './CustomSelect/index.js' import {
type OptionWithDescription,
Select,
} from './CustomSelect/index.js'
import { Pane } from './design-system/Pane.js' import { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js' import TextInput from './TextInput.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
export type ProviderManagerResult = { export type ProviderManagerResult = {
action: 'saved' | 'cancelled' action: 'saved' | 'cancelled'
@@ -48,6 +69,7 @@ type Screen =
| 'menu' | 'menu'
| 'select-preset' | 'select-preset'
| 'select-ollama-model' | 'select-ollama-model'
| 'codex-oauth'
| 'form' | 'form'
| 'select-active' | 'select-active'
| 'select-edit' | 'select-edit'
@@ -89,8 +111,8 @@ const FORM_STEPS: Array<{
{ {
key: 'model', key: 'model',
label: 'Default model', label: 'Default model',
placeholder: 'e.g. llama3.1:8b', placeholder: 'e.g. llama3.1:8b or glm-4.7, glm-4.7-flash',
helpText: 'Model name to use when this provider is active.', helpText: 'Model name(s) to use. Separate multiple with commas; first is default.',
}, },
{ {
key: 'apiKey', key: 'apiKey',
@@ -105,6 +127,8 @@ const GITHUB_PROVIDER_ID = '__github_models__'
const GITHUB_PROVIDER_LABEL = 'GitHub Models' const GITHUB_PROVIDER_LABEL = 'GitHub Models'
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot' const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference' const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
type GithubCredentialSource = 'stored' | 'env' | 'none' type GithubCredentialSource = 'stored' | 'env' | 'none'
@@ -132,7 +156,12 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
const keyInfo = profile.apiKey ? 'key set' : 'no key' const keyInfo = profile.apiKey ? 'key set' : 'no key'
const providerKind = const providerKind =
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible' profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}` const models = parseModelList(profile.model)
const modelDisplay =
models.length <= 3
? models.join(', ')
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${keyInfo}${activeSuffix}`
} }
function getGithubCredentialSourceFromEnv( function getGithubCredentialSourceFromEnv(
@@ -193,7 +222,136 @@ function getGithubProviderSummary(
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}` return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}`
} }
function describeOllamaSelectionIssue(
readiness: OllamaGenerationReadiness,
baseUrl: string,
): string {
if (readiness.state === 'unreachable') {
return `Could not reach Ollama at ${redactUrlForDisplay(baseUrl)}. Start Ollama first, or enter the endpoint manually.`
}
if (readiness.state === 'no_models') {
return 'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.'
}
if (readiness.state === 'generation_failed') {
const modelHint = readiness.probeModel ?? 'the selected model'
const detailSuffix = readiness.detail
? ` Details: ${readiness.detail}.`
: ''
return `Ollama is reachable and models are installed, but a generation probe failed for ${modelHint}.${detailSuffix} Run "ollama run ${modelHint}" once and retry, or enter details manually.`
}
return ''
}
function findCodexOAuthProfile(
profiles: ProviderProfile[],
profileId?: string,
): ProviderProfile | undefined {
if (!profileId) {
return undefined
}
return profiles.find(profile => profile.id === profileId)
}
function isCodexOAuthProfile(
profile: ProviderProfile | null | undefined,
profileId?: string,
): boolean {
return Boolean(profile && profileId && profile.id === profileId)
}
function CodexOAuthSetup({
onBack,
onConfigured,
}: {
onBack: () => void
onConfigured: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => {
await onConfigured(tokens, persistCredentials)
}, [onConfigured])
useKeybinding('confirm:no', onBack, [onBack])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Box flexDirection="column" gap={1}>
<Text color="error" bold>
Codex OAuth failed
</Text>
<Text>{status.message}</Text>
<Text dimColor>Press Enter or Esc to go back.</Text>
<Select
options={[
{
value: 'back',
label: 'Back',
description: 'Return to provider presets',
},
]}
onChange={onBack}
onCancel={onBack}
visibleOptionCount={1}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Codex OAuth
</Text>
<Text>
Sign in with your ChatGPT account in the browser. OpenClaude will store
the resulting Codex credentials securely and switch this session to the
new Codex login when setup completes.
</Text>
{status.state === 'starting' ? (
<Text dimColor>Starting local callback and preparing your browser...</Text>
) : status.browserOpened === false ? (
<>
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
<Text>{status.authUrl}</Text>
</>
) : status.browserOpened === true ? (
<>
<Text dimColor>
Browser opened. Finish the ChatGPT sign-in there and this setup will
complete automatically.
</Text>
<Text>{status.authUrl}</Text>
</>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
)
}
export function ProviderManager({ mode, onDone }: Props): React.ReactNode { export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState()
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv() const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const initialHasGithubCredential = initialGithubCredentialSource !== 'none' const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
@@ -212,6 +370,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] = const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive) React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0) const githubRefreshEpochRef = React.useRef(0)
const codexRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>( const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu', mode === 'first-run' ? 'select-preset' : 'menu',
) )
@@ -226,6 +385,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [cursorOffset, setCursorOffset] = React.useState(0) const [cursorOffset, setCursorOffset] = React.useState(0)
const [statusMessage, setStatusMessage] = React.useState<string | undefined>() const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
const [errorMessage, setErrorMessage] = React.useState<string | undefined>() const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
const [menuFocusValue, setMenuFocusValue] = React.useState<string | undefined>()
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
React.useState(false)
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
React.useState<string | undefined>()
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({ const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
state: 'idle', state: 'idle',
}) })
@@ -263,19 +427,91 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
})() })()
}, []) }, [])
const refreshCodexOAuthCredentialState = React.useCallback((): void => {
if (isBareMode()) {
codexRefreshEpochRef.current += 1
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
return
}
const refreshEpoch = ++codexRefreshEpochRef.current
void (async () => {
const credentials = await readCodexCredentialsAsync()
if (refreshEpoch !== codexRefreshEpochRef.current) {
return
}
setHasStoredCodexOAuthCredentials(
Boolean(
credentials?.apiKey ||
credentials?.accessToken ||
credentials?.refreshToken ||
credentials?.idToken,
),
)
setStoredCodexOAuthProfileId(credentials?.profileId)
})()
}, [])
React.useEffect(() => { React.useEffect(() => {
refreshGithubProviderState() refreshGithubProviderState()
refreshCodexOAuthCredentialState()
return () => { return () => {
githubRefreshEpochRef.current += 1 githubRefreshEpochRef.current += 1
codexRefreshEpochRef.current += 1
} }
}, [refreshGithubProviderState]) }, [refreshCodexOAuthCredentialState, refreshGithubProviderState])
React.useEffect(() => {
if (screen !== 'select-ollama-model') {
return
}
let cancelled = false
setOllamaSelection({ state: 'loading' })
void (async () => {
const readiness = await probeOllamaGenerationReadiness({
baseUrl: draft.baseUrl,
})
if (readiness.state !== 'ready') {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message: describeOllamaSelectionIssue(readiness, draft.baseUrl),
})
}
return
}
const ranked = rankOllamaModels(readiness.models, 'balanced')
const recommended = recommendOllamaModel(readiness.models, 'balanced')
if (!cancelled) {
setOllamaSelection({
state: 'ready',
defaultValue: recommended?.name ?? ranked[0]?.name,
options: ranked.map(model => ({
label: model.name,
value: model.name,
description: model.summary,
})),
})
}
})()
return () => {
cancelled = true
}
}, [draft.baseUrl, screen])
function refreshProfiles(): void { function refreshProfiles(): void {
const nextProfiles = getProviderProfiles() const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles) setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id) setActiveProfileId(getActiveProviderProfile()?.id)
refreshGithubProviderState() refreshGithubProviderState()
refreshCodexOAuthCredentialState()
} }
function clearStartupProviderOverrideFromUserSettings(): string | null { function clearStartupProviderOverrideFromUserSettings(): string | null {
@@ -292,6 +528,152 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return error ? error.message : null return error ? error.message : null
} }
function buildCodexOAuthActivationMessage(options: {
prefix: string
activationWarning: string | null
warnings: string[]
}): string {
if (options.activationWarning) {
return `${options.prefix}. Saved for next startup. Warning: ${options.warnings.join('; ')}.`
}
if (options.warnings.length > 0) {
return `${options.prefix}. OpenClaude switched to it for this session with warnings: ${options.warnings.join('; ')}.`
}
return `${options.prefix}. OpenClaude switched to it for this session.`
}
async function activateCodexOAuthSession(tokens?: {
accessToken: string
refreshToken?: string
accountId?: string
idToken?: string
}): Promise<string | null> {
const oauthEnv = buildCodexOAuthProfileEnv({
accessToken: tokens?.accessToken ?? '',
accountId: tokens?.accountId,
idToken: tokens?.idToken,
})
if (oauthEnv) {
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', oauthEnv),
})
}
const storedCredentials = await readCodexCredentialsAsync()
if (!storedCredentials) {
return 'stored Codex OAuth credentials could not be loaded'
}
const storedEnv = buildCodexOAuthProfileEnv({
accessToken: storedCredentials.accessToken,
accountId: storedCredentials.accountId,
idToken: storedCredentials.idToken,
})
if (!storedEnv) {
return 'stored Codex OAuth credentials are missing a ChatGPT account id'
}
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', storedEnv),
})
}
async function activateSelectedProvider(profileId: string): Promise<void> {
let providerLabel = 'provider'
try {
if (profileId === GITHUB_PROVIDER_ID) {
providerLabel = GITHUB_PROVIDER_LABEL
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
returnToMenu()
return
}
setAppState(prev => ({
...prev,
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
mainLoopModelForSession: null,
}))
refreshProfiles()
setAppState(prev => ({
...prev,
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
}))
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
returnToMenu()
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
returnToMenu()
return
}
// Update the session model to the new provider's first model.
// persistActiveProviderProfileModel (called by onChangeAppState) will
// not overwrite the multi-model list because it checks if the model
// is already in the profile's comma-separated model list.
const newModel = getPrimaryModel(active.model)
setAppState(prev => ({
...prev,
mainLoopModel: newModel,
}))
providerLabel = active.name
setAppState(prev => ({
...prev,
mainLoopModel: active.model,
mainLoopModelForSession: null,
}))
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const isActiveCodexOAuth = isCodexOAuthProfile(
active,
storedCodexOAuthProfileId,
)
const activationWarning = isActiveCodexOAuth
? await activateCodexOAuthSession()
: null
refreshProfiles()
setStatusMessage(
isActiveCodexOAuth
? buildCodexOAuthActivationMessage({
prefix: `Active provider: ${active.name}`,
activationWarning,
warnings: [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning)),
})
: settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
returnToMenu()
} catch (error) {
refreshProfiles()
setStatusMessage(undefined)
const detail = error instanceof Error ? error.message : String(error)
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
returnToMenu()
}
}
function returnToMenu(): void {
setMenuFocusValue('done')
setScreen('menu')
}
function closeWithCancelled(message: string): void { function closeWithCancelled(message: string): void {
onDone({ action: 'cancelled', message }) onDone({ action: 'cancelled', message })
} }
@@ -383,59 +765,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return null return null
} }
React.useEffect(() => {
if (screen !== 'select-ollama-model') {
return
}
let cancelled = false
setOllamaSelection({ state: 'loading' })
void (async () => {
const available = await hasLocalOllama(draft.baseUrl)
if (!available) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message:
'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.',
})
}
return
}
const models = await listOllamaModels(draft.baseUrl)
if (models.length === 0) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message:
'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.',
})
}
return
}
const ranked = rankOllamaModels(models, 'balanced')
const recommended = recommendOllamaModel(models, 'balanced')
if (!cancelled) {
setOllamaSelection({
state: 'ready',
defaultValue: recommended?.name ?? ranked[0]?.name,
options: ranked.map(model => ({
label: model.name,
value: model.name,
description: model.summary,
})),
})
}
})()
return () => {
cancelled = true
}
}, [draft.baseUrl, screen])
function startCreateFromPreset(preset: ProviderPreset): void { function startCreateFromPreset(preset: ProviderPreset): void {
const defaults = getProviderPresetDefaults(preset) const defaults = getProviderPresetDefaults(preset)
const nextDraft = { const nextDraft = {
@@ -495,6 +824,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
} }
const isActiveSavedProfile = getActiveProviderProfile()?.id === saved.id const isActiveSavedProfile = getActiveProviderProfile()?.id === saved.id
if (isActiveSavedProfile) {
setAppState(prev => ({
...prev,
mainLoopModel: saved.model,
mainLoopModelForSession: null,
}))
}
const settingsOverrideError = isActiveSavedProfile const settingsOverrideError = isActiveSavedProfile
? clearStartupProviderOverrideFromUserSettings() ? clearStartupProviderOverrideFromUserSettings()
: null : null
@@ -522,7 +858,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setEditingProfileId(null) setEditingProfileId(null)
setFormStepIndex(0) setFormStepIndex(0)
setErrorMessage(undefined) setErrorMessage(undefined)
setScreen('menu') returnToMenu()
} }
function renderOllamaSelection(): React.ReactNode { function renderOllamaSelection(): React.ReactNode {
@@ -557,7 +893,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Choose another provider preset', description: 'Choose another provider preset',
}, },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'manual') { if (value === 'manual') {
setFormStepIndex(0) setFormStepIndex(0)
setCursorOffset(draft.name.length) setCursorOffset(draft.name.length)
@@ -588,7 +924,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
defaultFocusValue={ollamaSelection.defaultValue} defaultFocusValue={ollamaSelection.defaultValue}
inlineDescriptions inlineDescriptions
visibleOptionCount={Math.min(8, ollamaSelection.options.length)} visibleOptionCount={Math.min(8, ollamaSelection.options.length)}
onChange={value => { onChange={(value: string) => {
const nextDraft = { const nextDraft = {
...draft, ...draft,
model: value, model: value,
@@ -645,7 +981,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return return
} }
setScreen('menu') returnToMenu()
} }
useKeybinding('confirm:no', handleBackFromForm, { useKeybinding('confirm:no', handleBackFromForm, {
@@ -654,6 +990,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}) })
function renderPresetSelection(): React.ReactNode { function renderPresetSelection(): React.ReactNode {
const canUseCodexOAuth = !isBareMode()
const options = [ const options = [
{ {
value: 'anthropic', value: 'anthropic',
@@ -670,6 +1007,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
label: 'OpenAI', label: 'OpenAI',
description: 'OpenAI API with API key', description: 'OpenAI API with API key',
}, },
...(canUseCodexOAuth
? [
{
value: 'codex-oauth',
label: 'Codex OAuth',
description:
'Sign in with ChatGPT in your browser and store Codex credentials securely',
},
]
: []),
{ {
value: 'moonshotai', value: 'moonshotai',
label: 'Moonshot AI', label: 'Moonshot AI',
@@ -715,11 +1062,31 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
label: 'LM Studio', label: 'LM Studio',
description: 'Local LM Studio endpoint', description: 'Local LM Studio endpoint',
}, },
{
value: 'dashscope-cn',
label: 'Alibaba Coding Plan (China)',
description: 'Alibaba DashScope China endpoint',
},
{
value: 'dashscope-intl',
label: 'Alibaba Coding Plan',
description: 'Alibaba DashScope International endpoint',
},
{ {
value: 'custom', value: 'custom',
label: 'Custom', label: 'Custom',
description: 'Any OpenAI-compatible provider', description: 'Any OpenAI-compatible provider',
}, },
{
value: 'nvidia-nim',
label: 'NVIDIA NIM',
description: 'NVIDIA NIM endpoint',
},
{
value: 'minimax',
label: 'MiniMax',
description: 'MiniMax API endpoint',
},
...(mode === 'first-run' ...(mode === 'first-run'
? [ ? [
{ {
@@ -741,11 +1108,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Text> </Text>
<Select <Select
options={options} options={options}
onChange={value => { onChange={(value: string) => {
if (value === 'skip') { if (value === 'skip') {
closeWithCancelled('Provider setup skipped') closeWithCancelled('Provider setup skipped')
return return
} }
if (value === 'codex-oauth') {
setScreen('codex-oauth')
return
}
startCreateFromPreset(value as ProviderPreset) startCreateFromPreset(value as ProviderPreset)
}} }}
onCancel={() => { onCancel={() => {
@@ -753,9 +1124,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
closeWithCancelled('Provider setup skipped') closeWithCancelled('Provider setup skipped')
return return
} }
setScreen('menu') returnToMenu()
}} }}
visibleOptionCount={Math.min(12, options.length)} visibleOptionCount={Math.min(13, options.length)}
/> />
</Box> </Box>
) )
@@ -791,6 +1162,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
focus={true} focus={true}
showCursor={true} showCursor={true}
placeholder={`${currentStep.placeholder}${figures.ellipsis}`} placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
mask={currentStepKey === 'apiKey' ? '*' : undefined}
columns={80} columns={80}
cursorOffset={cursorOffset} cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset} onChangeCursorOffset={setCursorOffset}
@@ -832,6 +1204,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Remove a provider profile', description: 'Remove a provider profile',
disabled: !hasSelectableProviders, disabled: !hasSelectableProviders,
}, },
...(hasStoredCodexOAuthCredentials
? [
{
value: 'logout-codex-oauth',
label: 'Log out Codex OAuth',
description: 'Clear securely stored Codex OAuth credentials',
},
]
: []),
{ {
value: 'done', value: 'done',
label: 'Done', label: 'Done',
@@ -876,7 +1257,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Box> </Box>
<Select <Select
options={options} options={options}
onChange={value => { onChange={(value: string) => {
setErrorMessage(undefined) setErrorMessage(undefined)
switch (value) { switch (value) {
case 'add': case 'add':
@@ -897,12 +1278,54 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('select-delete') setScreen('select-delete')
} }
break break
case 'logout-codex-oauth': {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Could not clear Codex OAuth credentials.',
)
break
}
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
const codexProfile = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
let settingsOverrideError: string | null = null
if (codexProfile) {
const result = deleteProviderProfile(codexProfile.id)
if (!result.removed) {
setErrorMessage(
'Codex OAuth credentials were cleared, but the Codex profile could not be removed.',
)
refreshProfiles()
break
}
clearPersistedCodexOAuthProfile()
settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings()
: null
}
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).`
: 'Codex OAuth logged out.',
)
break
}
default: default:
closeWithCancelled('Provider manager closed') closeWithCancelled('Provider manager closed')
break break
} }
}} }}
onCancel={() => closeWithCancelled('Provider manager closed')} onCancel={() => closeWithCancelled('Provider manager closed')}
defaultFocusValue={menuFocusValue}
visibleOptionCount={options.length} visibleOptionCount={options.length}
/> />
</Box> </Box>
@@ -950,8 +1373,8 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Return to provider manager', description: 'Return to provider manager',
}, },
]} ]}
onChange={() => setScreen('menu')} onChange={() => returnToMenu()}
onCancel={() => setScreen('menu')} onCancel={() => returnToMenu()}
visibleOptionCount={1} visibleOptionCount={1}
/> />
</Box> </Box>
@@ -966,7 +1389,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
<Select <Select
options={selectOptions} options={selectOptions}
onChange={onSelect} onChange={onSelect}
onCancel={() => setScreen('menu')} onCancel={() => returnToMenu()}
visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))} visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))}
/> />
</Box> </Box>
@@ -982,6 +1405,82 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'select-ollama-model': case 'select-ollama-model':
content = renderOllamaSelection() content = renderOllamaSelection()
break break
case 'codex-oauth':
content = (
<CodexOAuthSetup
onBack={() => setScreen('select-preset')}
onConfigured={async (tokens, persistCredentials) => {
const payload: ProviderProfileInput = {
provider: 'openai',
name: CODEX_OAUTH_PROVIDER_NAME,
baseUrl: DEFAULT_CODEX_BASE_URL,
model: CODEX_OAUTH_PROVIDER_MODEL,
apiKey: '',
}
const existing = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
const saved = existing
? updateProviderProfile(existing.id, payload)
: addProviderProfile(payload, { makeActive: true })
if (!saved) {
setErrorMessage(
'Codex OAuth login finished, but the provider profile could not be saved.',
)
returnToMenu()
return
}
const active =
existing && activeProfileId !== saved.id
? setActiveProviderProfile(saved.id)
: saved
if (!active) {
setErrorMessage(
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
)
returnToMenu()
return
}
persistCredentials({ profileId: saved.id })
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const activationWarning = await activateCodexOAuthSession(tokens)
setHasStoredCodexOAuthCredentials(true)
setStoredCodexOAuthProfileId(saved.id)
refreshProfiles()
const warnings = [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning))
const message = buildCodexOAuthActivationMessage({
prefix: 'Codex OAuth configured',
activationWarning,
warnings,
})
if (mode === 'first-run') {
onDone({
action: 'saved',
activeProfileId: active.id,
message,
})
return
}
setStatusMessage(message)
setErrorMessage(undefined)
returnToMenu()
}}
/>
)
break
case 'form': case 'form':
content = renderForm() content = renderForm()
break break
@@ -990,34 +1489,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
'Set active provider', 'Set active provider',
'No providers available. Add one first.', 'No providers available. Add one first.',
profileId => { profileId => {
if (profileId === GITHUB_PROVIDER_ID) { void activateSelectedProvider(profileId)
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
setScreen('menu')
return
}
refreshProfiles()
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
setScreen('menu')
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
setScreen('menu')
return
}
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
setScreen('menu')
}, },
{ includeGithub: true }, { includeGithub: true },
) )
@@ -1044,14 +1516,31 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
refreshProfiles() refreshProfiles()
setStatusMessage('GitHub provider deleted') setStatusMessage('GitHub provider deleted')
} }
setScreen('menu') returnToMenu()
return return
} }
const deletedCodexOAuthProfile =
findCodexOAuthProfile(
profiles,
storedCodexOAuthProfileId,
)?.id === profileId
const result = deleteProviderProfile(profileId) const result = deleteProviderProfile(profileId)
if (!result.removed) { if (!result.removed) {
setErrorMessage('Could not delete provider.') setErrorMessage('Could not delete provider.')
} else { } else {
if (deletedCodexOAuthProfile) {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Provider deleted, but Codex OAuth credentials could not be cleared.',
)
} else {
setStoredCodexOAuthProfileId(undefined)
}
clearPersistedCodexOAuthProfile()
}
const settingsOverrideError = result.activeProfileId const settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings() ? clearStartupProviderOverrideFromUserSettings()
: null : null
@@ -1062,7 +1551,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
: 'Provider deleted', : 'Provider deleted',
) )
} }
setScreen('menu') returnToMenu()
}, },
{ includeGithub: true }, { includeGithub: true },
) )

View File

@@ -5,7 +5,7 @@
* Addresses: https://github.com/Gitlawb/openclaude/issues/55 * Addresses: https://github.com/Gitlawb/openclaude/issues/55
*/ */
import { isLocalProviderUrl } from '../services/api/providerConfig.js' import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/providerConfig.js'
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js' import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
import { getSettings_DEPRECATED } from '../utils/settings/settings.js' import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js' import { parseUserSpecifiedModel } from '../utils/model/model.js'
@@ -110,39 +110,40 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
if (useOpenAI) { if (useOpenAI) {
const rawModel = process.env.OPENAI_MODEL || 'gpt-4o' const rawModel = process.env.OPENAI_MODEL || 'gpt-4o'
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' const resolvedRequest = resolveProviderRequest({
model: rawModel,
baseUrl: process.env.OPENAI_BASE_URL,
})
const baseUrl = resolvedRequest.baseUrl
const isLocal = isLocalProviderUrl(baseUrl) const isLocal = isLocalProviderUrl(baseUrl)
let name = 'OpenAI' let name = 'OpenAI'
if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek' if (/nvidia/i.test(baseUrl) || /nvidia/i.test(rawModel) || process.env.NVIDIA_NIM)
else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter' name = 'NVIDIA NIM'
else if (/together/i.test(baseUrl)) name = 'Together AI' else if (/minimax/i.test(baseUrl) || /minimax/i.test(rawModel) || process.env.MINIMAX_API_KEY)
else if (/groq/i.test(baseUrl)) name = 'Groq' name = 'MiniMax'
else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel)) name = 'Mistral' else if (resolvedRequest.transport === 'codex_responses' || baseUrl.includes('chatgpt.com/backend-api/codex'))
else if (/azure/i.test(baseUrl)) name = 'Azure OpenAI' name = 'Codex'
else if (/llama/i.test(rawModel)) name = 'Meta Llama' else if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel))
else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl) name = 'DeepSeek'
else if (/openrouter/i.test(baseUrl))
name = 'OpenRouter'
else if (/together/i.test(baseUrl))
name = 'Together AI'
else if (/groq/i.test(baseUrl))
name = 'Groq'
else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel))
name = 'Mistral'
else if (/azure/i.test(baseUrl))
name = 'Azure OpenAI'
else if (/llama/i.test(rawModel))
name = 'Meta Llama'
else if (isLocal)
name = getLocalOpenAICompatibleProviderLabel(baseUrl)
// Resolve model alias to actual model name + reasoning effort // Resolve model alias to actual model name + reasoning effort
let displayModel = rawModel let displayModel = resolvedRequest.resolvedModel
const codexAliases: Record<string, { model: string; reasoningEffort?: string }> = { if (resolvedRequest.reasoning?.effort) {
codexplan: { model: 'gpt-5.4', reasoningEffort: 'high' }, displayModel = `${displayModel} (${resolvedRequest.reasoning.effort})`
'gpt-5.4': { model: 'gpt-5.4', reasoningEffort: 'high' },
'gpt-5.3-codex': { model: 'gpt-5.3-codex', reasoningEffort: 'high' },
'gpt-5.3-codex-spark': { model: 'gpt-5.3-codex-spark' },
codexspark: { model: 'gpt-5.3-codex-spark' },
'gpt-5.2-codex': { model: 'gpt-5.2-codex', reasoningEffort: 'high' },
'gpt-5.1-codex-max': { model: 'gpt-5.1-codex-max', reasoningEffort: 'high' },
'gpt-5.1-codex-mini': { model: 'gpt-5.1-codex-mini' },
'gpt-5.4-mini': { model: 'gpt-5.4-mini', reasoningEffort: 'medium' },
'gpt-5.2': { model: 'gpt-5.2', reasoningEffort: 'medium' },
}
const alias = rawModel.toLowerCase()
if (alias in codexAliases) {
const resolved = codexAliases[alias]
displayModel = resolved.model
if (resolved.reasoningEffort) {
displayModel = `${displayModel} (${resolved.reasoningEffort})`
}
} }
return { name, model: displayModel, baseUrl, isLocal } return { name, model: displayModel, baseUrl, isLocal }
@@ -152,7 +153,9 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
const settings = getSettings_DEPRECATED() || {} const settings = getSettings_DEPRECATED() || {}
const modelSetting = settings.model || process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6' const modelSetting = settings.model || process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6'
const resolvedModel = parseUserSpecifiedModel(modelSetting) const resolvedModel = parseUserSpecifiedModel(modelSetting)
return { name: 'Anthropic', model: resolvedModel, baseUrl: 'https://api.anthropic.com', isLocal: false } const baseUrl = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com'
const isLocal = isLocalProviderUrl(baseUrl)
return { name: 'Anthropic', model: resolvedModel, baseUrl, isLocal }
} }
// ─── Box drawing ────────────────────────────────────────────────────────────── // ─── Box drawing ──────────────────────────────────────────────────────────────

View File

@@ -6,6 +6,7 @@ import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js' import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js' import { AppStateProvider } from '../state/AppState.js'
import { maskTextWithVisibleEdges } from '../utils/Cursor.js'
import TextInput from './TextInput.js' import TextInput from './TextInput.js'
import VimTextInput from './VimTextInput.js' import VimTextInput from './VimTextInput.js'
@@ -199,6 +200,13 @@ test('TextInput renders typed characters before delayed parent value commits', a
expect(output).not.toContain('Type here...') expect(output).not.toContain('Type here...')
}) })
test('maskTextWithVisibleEdges preserves only the first and last three chars', () => {
expect(maskTextWithVisibleEdges('sk-secret-12345678', '*')).toBe(
'sk-************678',
)
expect(maskTextWithVisibleEdges('abcdef', '*')).toBe('******')
})
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => { test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
const { stdout, stdin, getOutput } = createTestStreams() const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({ const root = await createRoot({

View File

@@ -1,3 +1,4 @@
import { c as _c } from "react-compiler-runtime";
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import useStdin from '../../ink/hooks/use-stdin.js'; import useStdin from '../../ink/hooks/use-stdin.js';
@@ -119,8 +120,21 @@ export function ThemeProvider({
* accepts any ThemeSetting (including 'auto'). * accepts any ThemeSetting (including 'auto').
*/ */
export function useTheme() { export function useTheme() {
const { currentTheme, setThemeSetting } = useContext(ThemeContext); const $ = _c(3);
return [currentTheme, setThemeSetting] as const; const {
currentTheme,
setThemeSetting
} = useContext(ThemeContext);
let t0;
if ($[0] !== currentTheme || $[1] !== setThemeSetting) {
t0 = [currentTheme, setThemeSetting];
$[0] = currentTheme;
$[1] = setThemeSetting;
$[2] = t0;
} else {
t0 = $[2];
}
return t0;
} }
/** /**
@@ -131,10 +145,25 @@ export function useThemeSetting() {
return useContext(ThemeContext).themeSetting; return useContext(ThemeContext).themeSetting;
} }
export function usePreviewTheme() { export function usePreviewTheme() {
const { setPreviewTheme, savePreview, cancelPreview } = useContext(ThemeContext); const $ = _c(4);
return { const {
setPreviewTheme, setPreviewTheme,
savePreview, savePreview,
cancelPreview, cancelPreview
} = useContext(ThemeContext);
let t0;
if ($[0] !== cancelPreview || $[1] !== savePreview || $[2] !== setPreviewTheme) {
t0 = {
setPreviewTheme,
savePreview,
cancelPreview
}; };
$[0] = cancelPreview;
$[1] = savePreview;
$[2] = setPreviewTheme;
$[3] = t0;
} else {
t0 = $[3];
}
return t0;
} }

View File

@@ -2,7 +2,7 @@ import { c as _c } from "react-compiler-runtime";
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle';
import chalk from 'chalk'; import chalk from 'chalk';
import { mkdir } from 'fs/promises'; import { mkdir } from 'fs/promises';
import { join } from 'path'; import { basename, join } from 'path';
import * as React from 'react'; import * as React from 'react';
import { use, useEffect, useState } from 'react'; import { use, useEffect, useState } from 'react';
import { getOriginalCwd } from '../../bootstrap/state.js'; import { getOriginalCwd } from '../../bootstrap/state.js';
@@ -24,6 +24,7 @@ import { projectIsInGitRepo } from '../../utils/memory/versions.js';
import { updateSettingsForSource } from '../../utils/settings/settings.js'; import { updateSettingsForSource } from '../../utils/settings/settings.js';
import { Select } from '../CustomSelect/index.js'; import { Select } from '../CustomSelect/index.js';
import { ListItem } from '../design-system/ListItem.js'; import { ListItem } from '../design-system/ListItem.js';
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js';
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null; const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null;
@@ -48,8 +49,10 @@ export function MemoryFileSelector(t0) {
onCancel onCancel
} = t0; } = t0;
const existingMemoryFiles = use(getMemoryFiles()); const existingMemoryFiles = use(getMemoryFiles());
const originalCwd = getOriginalCwd();
const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md"); const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md");
const projectMemoryPath = join(getOriginalCwd(), "CLAUDE.md"); const projectMemoryPath = getProjectMemoryPathForSelector(existingMemoryFiles, originalCwd);
const projectMemoryFileName = basename(projectMemoryPath);
const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath); const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath);
const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath); const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath);
const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{ const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{
@@ -85,12 +88,12 @@ export function MemoryFileSelector(t0) {
} }
} }
let description; let description;
const isGit = projectIsInGitRepo(getOriginalCwd()); const isGit = projectIsInGitRepo(originalCwd);
if (file.type === "User" && !file.isNested) { if (file.type === "User" && !file.isNested) {
description = "Saved in ~/.claude/CLAUDE.md"; description = "Saved in ~/.claude/CLAUDE.md";
} else { } else {
if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) { if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) {
description = `${isGit ? "Checked in at" : "Saved in"} ./CLAUDE.md`; description = `${isGit ? "Checked in at" : "Saved in"} ./${projectMemoryFileName}`;
} else { } else {
if (file.parent) { if (file.parent) {
description = "@-imported"; description = "@-imported";

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import type { MemoryFileInfo } from '../../utils/claudemd.js'
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js'
function projectFile(path: string): MemoryFileInfo {
return {
path,
type: 'Project',
content: '',
}
}
describe('getProjectMemoryPathForSelector', () => {
test('uses the loaded repo-level AGENTS.md from a nested cwd', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(join(repoDir, 'AGENTS.md'))],
nestedDir,
),
).toBe(join(repoDir, 'AGENTS.md'))
})
test('uses the loaded repo-level CLAUDE.md fallback from a nested cwd', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(join(repoDir, 'CLAUDE.md'))],
nestedDir,
),
).toBe(join(repoDir, 'CLAUDE.md'))
})
test('prefers the closest loaded ancestor instruction file', () => {
const repoDir = '/repo'
const nestedProjectDir = join(repoDir, 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[
projectFile(join(repoDir, 'AGENTS.md')),
projectFile(join(nestedProjectDir, 'CLAUDE.md')),
],
join(nestedProjectDir, 'src'),
),
).toBe(join(nestedProjectDir, 'CLAUDE.md'))
})
test('defaults to a new AGENTS.md in the current cwd when no project file is loaded', () => {
const cwd = join('/repo', 'packages', 'app')
expect(getProjectMemoryPathForSelector([], cwd)).toBe(
join(cwd, 'AGENTS.md'),
)
})
test('ignores loaded project instruction files outside the current cwd ancestry', () => {
const outsideRepoPath = join('/other-worktree', 'AGENTS.md')
const cwd = join('/repo', 'packages', 'app')
expect(
getProjectMemoryPathForSelector(
[projectFile(outsideRepoPath)],
cwd,
),
).toBe(join(cwd, 'AGENTS.md'))
})
})

View File

@@ -0,0 +1,34 @@
import { basename, join } from 'path'
import type { MemoryFileInfo } from '../../utils/claudemd.js'
import {
findProjectInstructionFilePathInAncestors,
isProjectInstructionFileName,
PRIMARY_PROJECT_INSTRUCTION_FILE,
} from '../../utils/projectInstructions.js'
function isLoadedProjectInstructionFile(file: MemoryFileInfo): boolean {
return (
file.type === 'Project' &&
file.parent === undefined &&
isProjectInstructionFileName(basename(file.path))
)
}
export function getProjectMemoryPathForSelector(
existingMemoryFiles: MemoryFileInfo[],
cwd: string,
): string {
const loadedProjectInstructionPaths = new Set(
existingMemoryFiles
.filter(isLoadedProjectInstructionFile)
.map(file => file.path),
)
return (
findProjectInstructionFilePathInAncestors(
cwd,
path => loadedProjectInstructionPaths.has(path),
) ?? join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
)
}

View File

@@ -0,0 +1,173 @@
import React from 'react'
import { getOriginalCwd } from '../../../bootstrap/state.js'
import { Box, Text } from '../../../ink.js'
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
import { env } from '../../../utils/env.js'
import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'
import { usePermissionRequestLogging } from '../hooks.js'
import { PermissionDialog } from '../PermissionDialog.js'
import {
PermissionPrompt,
type PermissionPromptOption,
} from '../PermissionPrompt.js'
import type { PermissionRequestProps } from '../PermissionRequest.js'
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'
import { logUnaryPermissionEvent } from '../utils.js'
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
export function MonitorPermissionRequest({
toolUseConfirm,
onDone,
onReject,
workerBadge,
}: PermissionRequestProps) {
const { command, description } = toolUseConfirm.input as {
command?: string
description?: string
}
usePermissionRequestLogging(toolUseConfirm, {
completion_type: 'tool_use_single',
language_name: 'none',
})
const handleSelect = (
value: OptionValue,
feedback?: string,
) => {
switch (value) {
case 'yes': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback)
onDone()
break
}
case 'yes-dont-ask-again': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
// Save the rule under 'Bash' toolName because checkPermissions
// delegates to bashToolHasPermission which matches rules against
// BashTool. Using 'Monitor' here would create a rule that's never
// checked. Command-specific prefix (like BashTool's shellRuleMatching).
const cmdForRule = command?.trim() || ''
const prefix = cmdForRule.split(/\s+/).slice(0, 2).join(' ')
toolUseConfirm.onAllow(toolUseConfirm.input, prefix ? [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: `${prefix}:*` }],
behavior: 'allow',
destination: 'localSettings',
},
] : [])
onDone()
break
}
case 'no': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onReject(feedback)
onReject()
onDone()
break
}
}
}
const handleCancel = () => {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onReject()
onReject()
onDone()
}
const showAlwaysAllow = shouldShowAlwaysAllowOptions()
const originalCwd = getOriginalCwd()
const options: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' },
},
]
if (showAlwaysAllow) {
options.push({
label: (
<Text>
Yes, and don&apos;t ask again for{' '}
<Text bold>Monitor</Text> commands in{' '}
<Text bold>{originalCwd}</Text>
</Text>
),
value: 'yes-dont-ask-again',
})
}
options.push({
label: 'No',
value: 'no',
feedbackConfig: { type: 'reject' },
})
const toolAnalyticsContext = {
toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
isMcp: toolUseConfirm.tool.isMcp ?? false,
}
return (
<PermissionDialog title="Monitor" workerBadge={workerBadge}>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>
Monitor({command ?? ''})
</Text>
{description ? (
<Text dimColor>{description}</Text>
) : null}
</Box>
<Box flexDirection="column">
<PermissionRuleExplanation
permissionResult={toolUseConfirm.permissionResult}
toolType="tool"
/>
<PermissionPrompt
options={options}
onSelect={handleSelect}
onCancel={handleCancel}
toolAnalyticsContext={toolAnalyticsContext}
/>
</Box>
</PermissionDialog>
)
}

View File

@@ -0,0 +1,220 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import { createRoot, Text } from '../ink.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
async function waitForCondition(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> {
const timeoutMs = options?.timeoutMs ?? 5000
const intervalMs = options?.intervalMs ?? 10
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(intervalMs)
}
throw new Error('Timed out waiting for useCodexOAuthFlow test condition')
}
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) break
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) break
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
const TOKENS = {
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
idToken: 'oauth-id-token',
apiKey: 'oauth-api-key',
}
afterEach(() => {
mock.restore()
})
test('does not persist credentials when downstream setup rejects', async () => {
const saveCodexCredentials = mock(() => ({ success: true }))
const cleanup = mock(() => {})
const onAuthenticated = mock(async () => {
throw new Error('profile save failed')
})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
return TOKENS
},
cleanup,
}),
openBrowser: async () => true,
saveCodexCredentials,
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?real-reject-${Date.now()}-${Math.random()}`
)
function Harness(): React.ReactNode {
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
deps,
})
return <Text>{status.state === 'error' ? status.message : status.state}</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
await Bun.sleep(0)
await Bun.sleep(0)
expect(onAuthenticated).toHaveBeenCalled()
expect(saveCodexCredentials).not.toHaveBeenCalled()
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})
test('persists credentials with profile linkage after downstream setup succeeds', async () => {
const saveCodexCredentials = mock(() => ({ success: true }))
const onAuthenticated = mock(
async (
_tokens: typeof TOKENS,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
persistCredentials({ profileId: 'profile_codex_oauth' })
},
)
const cleanup = mock(() => {})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
return TOKENS
},
cleanup,
}),
openBrowser: async () => true,
saveCodexCredentials,
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?real-persist-${Date.now()}-${Math.random()}`
)
function Harness(): React.ReactNode {
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
deps,
})
return <Text>waiting</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
await waitForCondition(() => saveCodexCredentials.mock.calls.length === 1)
expect(onAuthenticated).toHaveBeenCalled()
expect(saveCodexCredentials).toHaveBeenCalledWith({
apiKey: TOKENS.apiKey,
accessToken: TOKENS.accessToken,
refreshToken: TOKENS.refreshToken,
idToken: TOKENS.idToken,
accountId: TOKENS.accountId,
profileId: 'profile_codex_oauth',
})
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})

View File

@@ -0,0 +1,134 @@
import * as React from 'react'
import {
CodexOAuthService,
type CodexOAuthTokens,
} from '../services/api/codexOAuth.js'
import { openBrowser } from '../utils/browser.js'
import { saveCodexCredentials } from '../utils/codexCredentials.js'
import { isBareMode } from '../utils/envUtils.js'
export type CodexOAuthFlowStatus =
| { state: 'starting' }
| {
state: 'waiting'
authUrl: string
browserOpened: boolean | null
}
| {
state: 'error'
message: string
}
type PersistCodexOAuthCredentials = (options?: {
profileId?: string
}) => void
type CodexOAuthFlowDependencies = {
createOAuthService?: () => Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
>
openBrowser?: typeof openBrowser
saveCodexCredentials?: typeof saveCodexCredentials
isBareMode?: typeof isBareMode
}
function createDefaultOAuthService(): Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
> {
return new CodexOAuthService()
}
export function useCodexOAuthFlow(options: {
onAuthenticated: (
tokens: CodexOAuthTokens,
persistCredentials: PersistCodexOAuthCredentials,
) => void | Promise<void>
deps?: CodexOAuthFlowDependencies
}): CodexOAuthFlowStatus {
const { onAuthenticated } = options
const createOAuthService =
options.deps?.createOAuthService ?? createDefaultOAuthService
const openBrowserFn = options.deps?.openBrowser ?? openBrowser
const saveCredentials =
options.deps?.saveCodexCredentials ?? saveCodexCredentials
const isBareModeFn = options.deps?.isBareMode ?? isBareMode
const [status, setStatus] = React.useState<CodexOAuthFlowStatus>({
state: 'starting',
})
React.useEffect(() => {
if (isBareModeFn()) {
setStatus({
state: 'error',
message:
'Codex OAuth is unavailable in --bare because secure storage is disabled.',
})
return
}
let cancelled = false
const oauthService = createOAuthService()
void oauthService
.startOAuthFlow(async authUrl => {
if (cancelled) return
setStatus({
state: 'waiting',
authUrl,
browserOpened: null,
})
const browserOpened = await openBrowserFn(authUrl)
if (cancelled) return
setStatus({
state: 'waiting',
authUrl,
browserOpened,
})
})
.then(async tokens => {
if (cancelled) return
const persistCredentials: PersistCodexOAuthCredentials = options => {
const saved = saveCredentials({
apiKey: tokens.apiKey,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
idToken: tokens.idToken,
accountId: tokens.accountId,
profileId: options?.profileId,
})
if (!saved.success) {
throw new Error(
saved.warning ??
'Codex OAuth succeeded, but credentials could not be saved securely.',
)
}
}
await onAuthenticated(tokens, persistCredentials)
})
.catch(error => {
if (cancelled) return
setStatus({
state: 'error',
message: error instanceof Error ? error.message : String(error),
})
})
return () => {
cancelled = true
oauthService.cleanup()
}
}, [
createOAuthService,
isBareModeFn,
onAuthenticated,
openBrowserFn,
saveCredentials,
])
return status
}

View File

@@ -1,5 +1,16 @@
import { afterEach, expect, test } from 'bun:test' import { afterEach, expect, test } from 'bun:test'
// MACRO is replaced at build time by Bun.define but not in test mode.
// Define it globally so tests that import modules using MACRO don't crash.
;(globalThis as Record<string, unknown>).MACRO = {
VERSION: '99.0.0',
DISPLAY_VERSION: '0.0.0-test',
BUILD_TIME: new Date().toISOString(),
ISSUES_EXPLAINER: 'report the issue at https://github.com/anthropics/claude-code/issues',
PACKAGE_URL: '@gitlawb/openclaude',
NATIVE_PACKAGE_URL: undefined,
}
import { getSystemPrompt, DEFAULT_AGENT_PROMPT } from './prompts.js' import { getSystemPrompt, DEFAULT_AGENT_PROMPT } from './prompts.js'
import { CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } from './system.js' import { CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } from './system.js'
import { CLAUDE_CODE_GUIDE_AGENT } from '../tools/AgentTool/built-in/claudeCodeGuideAgent.js' import { CLAUDE_CODE_GUIDE_AGENT } from '../tools/AgentTool/built-in/claudeCodeGuideAgent.js'

View File

@@ -37,8 +37,6 @@ export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME, TASK_OUTPUT_TOOL_NAME,
EXIT_PLAN_MODE_V2_TOOL_NAME, EXIT_PLAN_MODE_V2_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME,
// Allow Agent tool for agents when user is ant (enables nested agents)
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME,
TASK_STOP_TOOL_NAME, TASK_STOP_TOOL_NAME,
// Prevent recursive workflow execution inside subagents. // Prevent recursive workflow execution inside subagents.
@@ -82,9 +80,9 @@ export const IN_PROCESS_TEAMMATE_ALLOWED_TOOLS = new Set([
SEND_MESSAGE_TOOL_NAME, SEND_MESSAGE_TOOL_NAME,
// Teammate-created crons are tagged with the creating agentId and routed to // Teammate-created crons are tagged with the creating agentId and routed to
// that teammate's pendingUserMessages queue (see useScheduledTasks.ts). // that teammate's pendingUserMessages queue (see useScheduledTasks.ts).
...(feature('AGENT_TRIGGERS') CRON_CREATE_TOOL_NAME,
? [CRON_CREATE_TOOL_NAME, CRON_DELETE_TOOL_NAME, CRON_LIST_TOOL_NAME] CRON_DELETE_TOOL_NAME,
: []), CRON_LIST_TOOL_NAME,
]) ])
/* /*

View File

@@ -0,0 +1,18 @@
import type { BuiltInAgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import { EXPLORE_AGENT } from '../tools/AgentTool/built-in/exploreAgent.js'
import { GENERAL_PURPOSE_AGENT } from '../tools/AgentTool/built-in/generalPurposeAgent.js'
import { PLAN_AGENT } from '../tools/AgentTool/built-in/planAgent.js'
// The coordinator system prompt instructs the model to spawn workers with
// subagent_type: "worker". This agent definition matches that type so
// AgentTool.tsx can resolve it. It reuses GENERAL_PURPOSE_AGENT's capabilities.
const WORKER_AGENT: BuiltInAgentDefinition = {
...GENERAL_PURPOSE_AGENT,
agentType: 'worker',
whenToUse:
'Worker agent for coordinator mode. Executes tasks autonomously — research, implementation, or verification.',
}
export function getCoordinatorAgents(): BuiltInAgentDefinition[] {
return [WORKER_AGENT, GENERAL_PURPOSE_AGENT, EXPLORE_AGENT, PLAN_AGENT]
}

View File

@@ -5,7 +5,7 @@ import {
} from '../utils/providerProfile.js' } from '../utils/providerProfile.js'
import { import {
getProviderValidationError, getProviderValidationError,
validateProviderEnvOrExit, validateProviderEnvForStartupOrExit,
} from '../utils/providerValidation.js' } from '../utils/providerValidation.js'
// OpenClaude: polyfill globalThis.File for Node < 20. // OpenClaude: polyfill globalThis.File for Node < 20.
@@ -132,7 +132,7 @@ async function main(): Promise<void> {
hydrateGithubModelsTokenFromSecureStorage() hydrateGithubModelsTokenFromSecureStorage()
} }
await validateProviderEnvOrExit() await validateProviderEnvForStartupOrExit()
// Print the gradient startup screen before the Ink UI loads // Print the gradient startup screen before the Ink UI loads
const { printStartupScreen } = await import('../components/StartupScreen.js') const { printStartupScreen } = await import('../components/StartupScreen.js')

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, mock } from 'bun:test'
import { getCombinedTools, loadReexposedMcpTools } from './mcp.js'
import type { Tool as InternalTool } from '../Tool.js'
import type { MCPServerConnection } from '../services/mcp/types.js'
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
// Mock the MCP client service to control the tools and connections returned
const mockGetMcpToolsCommandsAndResources = mock(async (onConnectionAttempt: any) => {})
mock.module('../services/mcp/client.js', () => ({
getMcpToolsCommandsAndResources: mockGetMcpToolsCommandsAndResources
}))
describe('getCombinedTools', () => {
it('deduplicates builtins when mcpTools have the same name, prioritizing mcpTools', () => {
const builtinBash = { name: 'Bash', isMcp: false } as unknown as InternalTool
const builtinRead = { name: 'Read', isMcp: false } as unknown as InternalTool
const mcpBash = { name: 'Bash', isMcp: true } as unknown as InternalTool
const builtins = [builtinBash, builtinRead]
const mcpTools = [mcpBash]
const result = getCombinedTools(builtins, mcpTools)
expect(result).toHaveLength(2)
expect(result[0]).toBe(mcpBash)
expect(result[1]).toBe(builtinRead)
})
})
describe('loadReexposedMcpTools', () => {
it('loads tools and clients regardless of connection state (including needs-auth)', async () => {
// Setup the mock to simulate yielding a needs-auth server and a connected server
mockGetMcpToolsCommandsAndResources.mockImplementation(async (onConnectionAttempt) => {
const needsAuthClient = {
name: 'auth-server',
type: 'needs-auth',
config: {}
} as MCPServerConnection
const authTool = {
name: 'mcp__auth-server__authenticate',
isMcp: true
} as unknown as InternalTool
const connectedClient = {
name: 'connected-server',
type: 'connected',
config: {},
client: {}
} as MCPServerConnection
const connectedTool = {
name: 'mcp__connected-server__do_thing',
isMcp: true
} as unknown as InternalTool
// Simulate the callback behavior
onConnectionAttempt({ client: needsAuthClient, tools: [authTool], commands: [] })
onConnectionAttempt({ client: connectedClient, tools: [connectedTool], commands: [] })
})
const { mcpClients, mcpTools } = await loadReexposedMcpTools()
expect(mcpClients).toHaveLength(2)
expect(mcpClients[0].type).toBe('needs-auth')
expect(mcpClients[1].type).toBe('connected')
expect(mcpTools).toHaveLength(2)
expect(mcpTools[0].name).toBe('mcp__auth-server__authenticate')
expect(mcpTools[1].name).toBe('mcp__connected-server__do_thing')
// Reset mock for other tests
mockGetMcpToolsCommandsAndResources.mockReset()
})
})

View File

@@ -7,6 +7,7 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true'
import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { ZodError } from 'zod'
import { import {
CallToolRequestSchema, CallToolRequestSchema,
type CallToolResult, type CallToolResult,
@@ -17,9 +18,12 @@ import {
import { getDefaultAppState } from 'src/state/AppStateStore.js' import { getDefaultAppState } from 'src/state/AppStateStore.js'
import review from '../commands/review.js' import review from '../commands/review.js'
import type { Command } from '../commands.js' import type { Command } from '../commands.js'
import { getMcpToolsCommandsAndResources } from '../services/mcp/client.js'
import type { MCPServerConnection } from '../services/mcp/types.js'
import { import {
findToolByName, findToolByName,
getEmptyToolPermissionContext, getEmptyToolPermissionContext,
type Tool as InternalTool,
type ToolUseContext, type ToolUseContext,
} from '../Tool.js' } from '../Tool.js'
import { getTools } from '../tools.js' import { getTools } from '../tools.js'
@@ -39,6 +43,32 @@ type ToolOutput = Tool['outputSchema']
const MCP_COMMANDS: Command[] = [review] const MCP_COMMANDS: Command[] = [review]
export function getCombinedTools(
builtins: InternalTool[],
mcpTools: InternalTool[],
): InternalTool[] {
const mcpToolNames = new Set(mcpTools.map(t => t.name))
const deduplicatedBuiltins = builtins.filter(t => !mcpToolNames.has(t.name))
return [...mcpTools, ...deduplicatedBuiltins]
}
export async function loadReexposedMcpTools(): Promise<{
mcpClients: MCPServerConnection[]
mcpTools: InternalTool[]
}> {
const mcpClients: MCPServerConnection[] = []
const mcpTools: InternalTool[] = []
// Load configured MCP clients and their tools
await getMcpToolsCommandsAndResources(({ client, tools: clientTools }) => {
mcpClients.push(client)
mcpTools.push(...clientTools)
})
return { mcpClients, mcpTools }
}
export async function startMCPServer( export async function startMCPServer(
cwd: string, cwd: string,
debug: boolean, debug: boolean,
@@ -63,12 +93,13 @@ export async function startMCPServer(
}, },
) )
const { mcpClients, mcpTools } = await loadReexposedMcpTools()
server.setRequestHandler( server.setRequestHandler(
ListToolsRequestSchema, ListToolsRequestSchema,
async (): Promise<ListToolsResult> => { async (): Promise<ListToolsResult> => {
// TODO: Also re-expose any MCP tools
const toolPermissionContext = getEmptyToolPermissionContext() const toolPermissionContext = getEmptyToolPermissionContext()
const tools = getTools(toolPermissionContext) const tools = getCombinedTools(getTools(toolPermissionContext), mcpTools)
return { return {
tools: await Promise.all( tools: await Promise.all(
tools.map(async tool => { tools.map(async tool => {
@@ -94,7 +125,7 @@ export async function startMCPServer(
tools, tools,
agents: [], agents: [],
}), }),
inputSchema: zodToJsonSchema(tool.inputSchema) as ToolInput, inputSchema: (tool.inputJSONSchema ?? zodToJsonSchema(tool.inputSchema)) as ToolInput,
outputSchema, outputSchema,
} }
}), }),
@@ -107,8 +138,7 @@ export async function startMCPServer(
CallToolRequestSchema, CallToolRequestSchema,
async ({ params: { name, arguments: args } }): Promise<CallToolResult> => { async ({ params: { name, arguments: args } }): Promise<CallToolResult> => {
const toolPermissionContext = getEmptyToolPermissionContext() const toolPermissionContext = getEmptyToolPermissionContext()
// TODO: Also re-expose any MCP tools const tools = getCombinedTools(getTools(toolPermissionContext), mcpTools)
const tools = getTools(toolPermissionContext)
const tool = findToolByName(tools, name) const tool = findToolByName(tools, name)
if (!tool) { if (!tool) {
throw new Error(`Tool ${name} not found`) throw new Error(`Tool ${name} not found`)
@@ -123,7 +153,7 @@ export async function startMCPServer(
tools, tools,
mainLoopModel: getMainLoopModel(), mainLoopModel: getMainLoopModel(),
thinkingConfig: { type: 'disabled' }, thinkingConfig: { type: 'disabled' },
mcpClients: [], mcpClients,
mcpResources: {}, mcpResources: {},
isNonInteractiveSession: true, isNonInteractiveSession: true,
debug, debug,
@@ -140,13 +170,16 @@ export async function startMCPServer(
updateAttributionState: () => {}, updateAttributionState: () => {},
} }
// TODO: validate input types with zod
try { try {
if (!tool.isEnabled()) { if (!tool.isEnabled()) {
throw new Error(`Tool ${name} is not enabled`) throw new Error(`Tool ${name} is not enabled`)
} }
// Validate input types with zod
const parsedArgs = tool.inputSchema.parse(args ?? {})
const validationResult = await tool.validateInput?.( const validationResult = await tool.validateInput?.(
(args as never) ?? {}, (parsedArgs as never) ?? {},
toolUseContext, toolUseContext,
) )
if (validationResult && !validationResult.result) { if (validationResult && !validationResult.result) {
@@ -155,7 +188,7 @@ export async function startMCPServer(
) )
} }
const finalResult = await tool.call( const finalResult = await tool.call(
(args ?? {}) as never, (parsedArgs ?? {}) as never,
toolUseContext, toolUseContext,
hasPermissionsToUseTool, hasPermissionsToUseTool,
createAssistantMessage({ createAssistantMessage({
@@ -163,20 +196,50 @@ export async function startMCPServer(
}), }),
) )
let content: CallToolResult['content']
const data = finalResult.data as string | { type: string; text?: string; source?: { type: string; media_type: string; data: string } }[] | unknown
if (typeof data === 'string') {
content = [{ type: 'text', text: data }]
} else if (Array.isArray(data)) {
content = data.map((block: any) => {
if (block.type === 'text') {
return { type: 'text', text: block.text || '' }
} else if (block.type === 'image' && block.source) {
return { return {
content: [ type: 'image',
{ data: block.source.data,
type: 'text' as const, mimeType: block.source.media_type,
text: }
typeof finalResult === 'string' } else {
? finalResult // eslint-disable-next-line custom-rules/no-top-level-side-effects, no-console
: jsonStringify(finalResult.data), console.warn(`Unmapped content block type from tool ${name}: ${block.type || 'unknown'}`)
}, return { type: 'text', text: jsonStringify(block) }
], }
}) as CallToolResult['content']
} else {
content = [{ type: 'text', text: jsonStringify(data) }]
}
return {
content,
isError: !!(finalResult as any).isError,
} }
} catch (error) { } catch (error) {
logError(error) logError(error)
if (error instanceof ZodError) {
return {
isError: true,
content: [
{
type: 'text',
text: `Tool ${name} input is invalid:\n${error.errors.map(e => `- ${e.path.join('.')}: ${e.message}`).join('\n')}`,
},
],
}
}
const parts = const parts =
error instanceof Error ? getErrorParts(error) : [String(error)] error instanceof Error ? getErrorParts(error) : [String(error)]
const errorText = parts.filter(Boolean).join('\n').trim() || 'Error' const errorText = parts.filter(Boolean).join('\n').trim() || 'Error'
@@ -201,3 +264,4 @@ export async function startMCPServer(
return await runServer() return await runServer()
} }

View File

@@ -114,8 +114,8 @@ export const SandboxSettingsSchema = lazySchema(() =>
.boolean() .boolean()
.optional() .optional()
.describe( .describe(
'Allow commands to run outside the sandbox via the dangerouslyDisableSandbox parameter. ' + 'Allow trusted, user-initiated commands to run outside the sandbox. ' +
'When false, the dangerouslyDisableSandbox parameter is completely ignored and all commands must run sandboxed. ' + 'When false, sandbox override requests are ignored and all commands must run sandboxed. ' +
'Default: true.', 'Default: true.',
), ),
network: SandboxNetworkConfigSchema(), network: SandboxNetworkConfigSchema(),

View File

@@ -0,0 +1,123 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import { createRoot, Text } from '../ink.js'
type AuthState = {
anthropicAuthEnabled: boolean
claudeSubscriber: boolean
key?: string
source?: string
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
} {
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
return { stdout, stdin }
}
async function waitForCondition(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for useApiKeyVerification test state')
}
afterEach(() => {
mock.restore()
})
test('useApiKeyVerification resets stale missing status when the session switches to a third-party provider', async () => {
const authState: AuthState = {
anthropicAuthEnabled: true,
claudeSubscriber: false,
}
const seenStatuses: string[] = []
mock.module('../utils/auth.js', () => ({
getAnthropicApiKeyWithSource: () => ({
key: authState.key,
source: authState.source,
}),
getApiKeyFromApiKeyHelper: async () => undefined,
isAnthropicAuthEnabled: () => authState.anthropicAuthEnabled,
isClaudeAISubscriber: () => authState.claudeSubscriber,
}))
mock.module('../bootstrap/state.js', () => ({
getIsNonInteractiveSession: () => false,
}))
mock.module('../services/api/claude.js', () => ({
verifyApiKey: async () => true,
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { useApiKeyVerification } = await import(
'./useApiKeyVerification.ts?switch-to-third-party'
)
function Harness(): React.ReactNode {
const { status } = useApiKeyVerification()
React.useEffect(() => {
seenStatuses.push(status)
}, [status])
return <Text>{status}</Text>
}
const { stdout, stdin } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
await waitForCondition(() => seenStatuses.includes('missing'))
authState.anthropicAuthEnabled = false
root.render(<Harness />)
await waitForCondition(() => seenStatuses.includes('valid'))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
expect(seenStatuses[0]).toBe('missing')
expect(seenStatuses).toContain('valid')
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { getIsNonInteractiveSession } from '../bootstrap/state.js' import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { verifyApiKey } from '../services/api/claude.js' import { verifyApiKey } from '../services/api/claude.js'
import { import {
@@ -21,8 +21,7 @@ export type ApiKeyVerificationResult = {
error: Error | null error: Error | null
} }
export function useApiKeyVerification(): ApiKeyVerificationResult { function getInitialVerificationStatus(): VerificationStatus {
const [status, setStatus] = useState<VerificationStatus>(() => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
return 'valid' return 'valid'
} }
@@ -37,8 +36,28 @@ export function useApiKeyVerification(): ApiKeyVerificationResult {
return 'loading' return 'loading'
} }
return 'missing' return 'missing'
}) }
export function useApiKeyVerification(): ApiKeyVerificationResult {
const [status, setStatus] = useState<VerificationStatus>(
getInitialVerificationStatus,
)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const anthropicVerificationEnabled =
isAnthropicAuthEnabled() && !isClaudeAISubscriber()
useEffect(() => {
const nextStatus = anthropicVerificationEnabled
? getInitialVerificationStatus()
: 'valid'
setStatus(currentStatus =>
currentStatus === nextStatus ? currentStatus : nextStatus,
)
if (nextStatus !== 'error') {
setError(null)
}
}, [anthropicVerificationEnabled])
const verify = useCallback(async (): Promise<void> => { const verify = useCallback(async (): Promise<void> => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {

View File

@@ -19,7 +19,7 @@ async function _temp() {
logForDebugging("Showing marketplace config save failure notification"); logForDebugging("Showing marketplace config save failure notification");
notifs.push({ notifs.push({
key: "marketplace-config-save-failed", key: "marketplace-config-save-failed",
jsx: <Text color="error">Failed to save marketplace retry info · Check ~/.claude.json permissions</Text>, jsx: <Text color="error">Failed to save marketplace retry info · Check ~/.openclaude.json permissions</Text>,
priority: "immediate", priority: "immediate",
timeoutMs: 10000 timeoutMs: 10000
}); });

View File

@@ -434,7 +434,7 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S
if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) { if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) {
return { return {
ok: false, ok: false,
error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions' error: 'Cannot set permission mode to bypassPermissions. Enable it with --allow-dangerously-skip-permissions or set permissions.allowBypassPermissionsMode in settings.json'
}; };
} }
} }

View File

@@ -1,34 +1,23 @@
/** /**
* Swarm Permission Poller Hook * Swarm Permission Callback Registry
* *
* This hook polls for permission responses from the team leader when running * Manages callback registrations for permission requests and responses
* as a worker agent in a swarm. When a response is received, it calls the * in agent swarms. Responses are delivered exclusively via the mailbox
* appropriate callback (onAllow/onReject) to continue execution. * system (useInboxPoller → processMailboxPermissionResponse).
* *
* This hook should be used in conjunction with the worker-side integration * The legacy file-based polling (resolved/ directory) has been removed
* in useCanUseTool.ts, which creates pending requests that this hook monitors. * because it created an unauthenticated attack surface — any local process
* could forge approval files. The mailbox path is the sole active channel.
*/ */
import { useCallback, useEffect, useRef } from 'react'
import { useInterval } from 'usehooks-ts'
import { logForDebugging } from '../utils/debug.js' import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { import {
type PermissionUpdate, type PermissionUpdate,
permissionUpdateSchema, permissionUpdateSchema,
} from '../utils/permissions/PermissionUpdateSchema.js' } from '../utils/permissions/PermissionUpdateSchema.js'
import {
isSwarmWorker,
type PermissionResponse,
pollForResponse,
removeWorkerResponse,
} from '../utils/swarm/permissionSync.js'
import { getAgentName, getTeamName } from '../utils/teammate.js'
const POLL_INTERVAL_MS = 500
/** /**
* Validate permissionUpdates from external sources (mailbox IPC, disk polling). * Validate permissionUpdates from external sources (mailbox IPC).
* Malformed entries from buggy/old teammate processes are filtered out rather * Malformed entries from buggy/old teammate processes are filtered out rather
* than propagated unchecked into callback.onAllow(). * than propagated unchecked into callback.onAllow().
*/ */
@@ -225,106 +214,9 @@ export function processSandboxPermissionResponse(params: {
return true return true
} }
/** // Legacy file-based polling (useSwarmPermissionPoller, processResponse)
* Process a permission response by invoking the registered callback // has been removed. Permission responses are now delivered exclusively
*/ // via the mailbox system:
function processResponse(response: PermissionResponse): boolean { // Leader: sendPermissionResponseViaMailbox() → writeToMailbox()
const callback = pendingCallbacks.get(response.requestId) // Worker: useInboxPoller → processMailboxPermissionResponse()
// See: fix(security) — remove unauthenticated file-based permission channel
if (!callback) {
logForDebugging(
`[SwarmPermissionPoller] No callback registered for request ${response.requestId}`,
)
return false
}
logForDebugging(
`[SwarmPermissionPoller] Processing response for request ${response.requestId}: ${response.decision}`,
)
// Remove from registry before invoking callback
pendingCallbacks.delete(response.requestId)
if (response.decision === 'approved') {
const permissionUpdates = parsePermissionUpdates(response.permissionUpdates)
const updatedInput = response.updatedInput
callback.onAllow(updatedInput, permissionUpdates)
} else {
callback.onReject(response.feedback)
}
return true
}
/**
* Hook that polls for permission responses when running as a swarm worker.
*
* This hook:
* 1. Only activates when isSwarmWorker() returns true
* 2. Polls every 500ms for responses
* 3. When a response is found, invokes the registered callback
* 4. Cleans up the response file after processing
*/
export function useSwarmPermissionPoller(): void {
const isProcessingRef = useRef(false)
const poll = useCallback(async () => {
// Don't poll if not a swarm worker
if (!isSwarmWorker()) {
return
}
// Prevent concurrent polling
if (isProcessingRef.current) {
return
}
// Don't poll if no callbacks are registered
if (pendingCallbacks.size === 0) {
return
}
isProcessingRef.current = true
try {
const agentName = getAgentName()
const teamName = getTeamName()
if (!agentName || !teamName) {
return
}
// Check each pending request for a response
for (const [requestId, _callback] of pendingCallbacks) {
const response = await pollForResponse(requestId, agentName, teamName)
if (response) {
// Process the response
const processed = processResponse(response)
if (processed) {
// Clean up the response from the worker's inbox
await removeWorkerResponse(requestId, agentName, teamName)
}
}
}
} catch (error) {
logForDebugging(
`[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`,
)
} finally {
isProcessingRef.current = false
}
}, [])
// Only poll if we're a swarm worker
const shouldPoll = isSwarmWorker()
useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null)
// Initial poll on mount
useEffect(() => {
if (isSwarmWorker()) {
void poll()
}
}, [poll])
}

View File

@@ -11,14 +11,16 @@ const execFileNoThrowMock = mock(
async () => ({ code: 0, stdout: '', stderr: '' }), async () => ({ code: 0, stdout: '', stderr: '' }),
) )
mock.module('../../utils/execFileNoThrow.js', () => ({ function installOscMocks(): void {
mock.module('../../utils/execFileNoThrow.js', () => ({
execFileNoThrow: execFileNoThrowMock, execFileNoThrow: execFileNoThrowMock,
execFileNoThrowWithCwd: execFileNoThrowMock, execFileNoThrowWithCwd: execFileNoThrowMock,
})) }))
mock.module('../../utils/tempfile.js', () => ({ mock.module('../../utils/tempfile.js', () => ({
generateTempFilePath: generateTempFilePathMock, generateTempFilePath: generateTempFilePathMock,
})) }))
}
async function importFreshOscModule() { async function importFreshOscModule() {
return import(`./osc.ts?ts=${Date.now()}-${Math.random()}`) return import(`./osc.ts?ts=${Date.now()}-${Math.random()}`)
@@ -45,6 +47,7 @@ async function waitForExecCall(
describe('Windows clipboard fallback', () => { describe('Windows clipboard fallback', () => {
beforeEach(() => { beforeEach(() => {
installOscMocks()
execFileNoThrowMock.mockClear() execFileNoThrowMock.mockClear()
generateTempFilePathMock.mockClear() generateTempFilePathMock.mockClear()
process.env = { ...originalEnv } process.env = { ...originalEnv }
@@ -62,14 +65,12 @@ describe('Windows clipboard fallback', () => {
const { setClipboard } = await importFreshOscModule() const { setClipboard } = await importFreshOscModule()
await setClipboard('Привет мир') await setClipboard('Привет мир')
await flushClipboardCopy() const windowsCall = await waitForExecCall('powershell')
expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe( expect(execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'clip')).toBe(
false, false,
) )
expect( expect(windowsCall).toBeDefined()
execFileNoThrowMock.mock.calls.some(([cmd]) => cmd === 'powershell'),
).toBe(true)
}) })
test('passes Windows clipboard text through a UTF-8 temp file instead of stdin', async () => { test('passes Windows clipboard text through a UTF-8 temp file instead of stdin', async () => {
@@ -97,6 +98,7 @@ describe('Windows clipboard fallback', () => {
describe('clipboard path behavior remains stable', () => { describe('clipboard path behavior remains stable', () => {
beforeEach(() => { beforeEach(() => {
installOscMocks()
execFileNoThrowMock.mockClear() execFileNoThrowMock.mockClear()
process.env = { ...originalEnv } process.env = { ...originalEnv }
delete process.env['SSH_CONNECTION'] delete process.env['SSH_CONNECTION']

View File

@@ -481,16 +481,16 @@ export const CLEAR_TAB_STATUS = osc(
) )
/** /**
* Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the * Gate for emitting OSC 21337 (tab-status indicator). Currently disabled
* spec is unstable. Terminals that don't recognize it discard silently, so * (spec is unstable). Terminals that don't recognize it discard silently,
* emission is safe unconditionally — we don't gate on terminal detection * so emission is safe unconditionally — we don't gate on terminal detection
* since support is expected across several terminals. * since support is expected across several terminals.
* *
* Callers must wrap output with wrapForMultiplexer() so tmux/screen * Callers must wrap output with wrapForMultiplexer() so tmux/screen
* DCS-passthrough carries the sequence to the outer terminal. * DCS-passthrough carries the sequence to the outer terminal.
*/ */
export function supportsTabStatus(): boolean { export function supportsTabStatus(): boolean {
return process.env.USER_TYPE === 'ant' return false
} }
/** /**

View File

@@ -74,7 +74,7 @@ export function isTeamMemoryEnabled(): boolean {
if (!isAutoMemoryEnabled()) { if (!isAutoMemoryEnabled()) {
return false return false
} }
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false) return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', true)
} }
/** /**

View File

@@ -12,7 +12,7 @@ import {
* One-shot migration: clear skipAutoPermissionPrompt for users who accepted * One-shot migration: clear skipAutoPermissionPrompt for users who accepted
* the old 2-option AutoModeOptInDialog but don't have auto as their default. * the old 2-option AutoModeOptInDialog but don't have auto as their default.
* Re-surfaces the dialog so they see the new "make it my default mode" option. * Re-surfaces the dialog so they see the new "make it my default mode" option.
* Guard lives in GlobalConfig (~/.claude.json), not settings.json, so it * Guard lives in GlobalConfig (~/.openclaude.json), not settings.json, so it
* survives settings resets and doesn't re-arm itself. * survives settings resets and doesn't re-arm itself.
* *
* Only runs when tengu_auto_mode_config.enabled === 'enabled'. For 'opt-in' * Only runs when tengu_auto_mode_config.enabled === 'enabled'. For 'opt-in'

View File

@@ -0,0 +1,62 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getSteps,
isProjectOnboardingComplete,
} from './projectOnboardingSteps.js'
import { runWithCwdOverride } from './utils/cwd.js'
let tempDir: string | undefined
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = undefined
}
})
describe('project onboarding completion', () => {
test('is incomplete when neither AGENTS.md nor CLAUDE.md exists', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
await runWithCwdOverride(tempDir, async () => {
expect(isProjectOnboardingComplete()).toBe(false)
expect(getSteps()[1]?.text).toContain('/init')
expect(getSteps()[1]?.text).toContain('AGENTS.md')
expect(getSteps()[1]?.text).toContain('CLAUDE.md')
})
})
test('is complete when only CLAUDE.md exists', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
await writeFile(join(tempDir, 'CLAUDE.md'), '# CLAUDE.md\n')
await runWithCwdOverride(tempDir, async () => {
expect(isProjectOnboardingComplete()).toBe(true)
})
})
test('is complete when only AGENTS.md exists', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
await runWithCwdOverride(tempDir, async () => {
expect(isProjectOnboardingComplete()).toBe(true)
})
})
test('is complete from a nested cwd when repo instructions exist in an ancestor directory', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
const nestedDir = join(tempDir, 'packages', 'app')
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
await mkdir(nestedDir, { recursive: true })
await writeFile(join(nestedDir, 'index.ts'), 'export {}\n')
await runWithCwdOverride(nestedDir, async () => {
expect(isProjectOnboardingComplete()).toBe(true)
})
})
})

View File

@@ -1,50 +1,14 @@
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { join } from 'path'
import { import {
getCurrentProjectConfig, getCurrentProjectConfig,
saveCurrentProjectConfig, saveCurrentProjectConfig,
} from './utils/config.js' } from './utils/config.js'
import { getCwd } from './utils/cwd.js' export {
import { isDirEmpty } from './utils/file.js' getSteps,
import { getFsImplementation } from './utils/fsOperations.js' isProjectOnboardingComplete,
type Step,
export type Step = { } from './projectOnboardingSteps.js'
key: string import { isProjectOnboardingComplete } from './projectOnboardingSteps.js'
text: string
isComplete: boolean
isCompletable: boolean
isEnabled: boolean
}
export function getSteps(): Step[] {
const hasClaudeMd = getFsImplementation().existsSync(
join(getCwd(), 'CLAUDE.md'),
)
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
return [
{
key: 'workspace',
text: 'Ask Claude to create a new app or clone a repository',
isComplete: false,
isCompletable: true,
isEnabled: isWorkspaceDirEmpty,
},
{
key: 'claudemd',
text: 'Run /init to create a CLAUDE.md file with instructions for Claude',
isComplete: hasClaudeMd,
isCompletable: true,
isEnabled: !isWorkspaceDirEmpty,
},
]
}
export function isProjectOnboardingComplete(): boolean {
return getSteps()
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
.every(({ isComplete }) => isComplete)
}
export function maybeMarkProjectOnboardingComplete(): void { export function maybeMarkProjectOnboardingComplete(): void {
// Short-circuit on cached config — isProjectOnboardingComplete() hits // Short-circuit on cached config — isProjectOnboardingComplete() hits

View File

@@ -0,0 +1,44 @@
import { getCwd } from './utils/cwd.js'
import { isDirEmpty } from './utils/file.js'
import { getFsImplementation } from './utils/fsOperations.js'
import { findProjectInstructionFilePathInAncestors } from './utils/projectInstructions.js'
export type Step = {
key: string
text: string
isComplete: boolean
isCompletable: boolean
isEnabled: boolean
}
export function getSteps(): Step[] {
const hasRepoInstructions =
findProjectInstructionFilePathInAncestors(
getCwd(),
getFsImplementation().existsSync,
) !== null
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
return [
{
key: 'workspace',
text: 'Ask Claude to create a new app or clone a repository',
isComplete: false,
isCompletable: true,
isEnabled: isWorkspaceDirEmpty,
},
{
key: 'claudemd',
text: 'Set up repo instructions (/init creates AGENTS.md or updates existing CLAUDE.md; either file counts)',
isComplete: hasRepoInstructions,
isCompletable: true,
isEnabled: !isWorkspaceDirEmpty,
},
]
}
export function isProjectOnboardingComplete(): boolean {
return getSteps()
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
.every(({ isComplete }) => isComplete)
}

View File

@@ -160,6 +160,7 @@ function* yieldMissingToolResultBlocks(
* rules, ye will be punished with an entire day of debugging and hair pulling. * rules, ye will be punished with an entire day of debugging and hair pulling.
*/ */
const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3
const MAX_CONTINUATION_NUDGES = 3
/** /**
* Is this a max_output_tokens error message? If so, the streaming loop should * Is this a max_output_tokens error message? If so, the streaming loop should
@@ -209,6 +210,10 @@ type State = {
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined stopHookActive: boolean | undefined
turnCount: number turnCount: number
// Count of consecutive continuation nudges within the current turn.
// Capped at MAX_CONTINUATION_NUDGES to prevent infinite nudge loops
// when the model keeps matching continuation signals without tool calls.
continuationNudgeCount: number
// Why the previous iteration continued. Undefined on first iteration. // Why the previous iteration continued. Undefined on first iteration.
// Lets tests assert recovery paths fired without inspecting message contents. // Lets tests assert recovery paths fired without inspecting message contents.
transition: Continue | undefined transition: Continue | undefined
@@ -272,6 +277,7 @@ async function* queryLoop(
maxOutputTokensRecoveryCount: 0, maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false, hasAttemptedReactiveCompact: false,
turnCount: 1, turnCount: 1,
continuationNudgeCount: 0,
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
transition: undefined, transition: undefined,
} }
@@ -645,6 +651,35 @@ async function* queryLoop(
} }
} }
// Safety net: when auto-compact's circuit breaker has tripped (3+
// consecutive failures), the normal blocking check above is gated on
// reactiveCompact. If reactiveCompact is also enabled but ALSO fails
// (or is disabled), the oversized context goes straight to the API and
// gets a 500. This check catches that gap — if compaction is exhausted
// and context is still over the autocompact threshold, block immediately
// with a clear message instead of burning an API call that will 500.
if (
tracking?.consecutiveFailures !== undefined &&
tracking.consecutiveFailures >= 3 &&
isAutoCompactEnabled()
) {
const model = toolUseContext.options.mainLoopModel
const tokenUsage = tokenCountWithEstimation(messagesForQuery) - snipTokensFreed
const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
tokenUsage,
model,
)
if (isAboveAutoCompactThreshold) {
yield createAssistantAPIErrorMessage({
content:
'The conversation has exceeded the context limit and automatic compaction has failed. ' +
'Press esc twice to go up a few messages and try again, or start a new session with /new.',
error: 'invalid_request',
})
return { reason: 'blocking_limit' }
}
}
let attemptWithFallback = true let attemptWithFallback = true
queryCheckpoint('query_api_loop_start') queryCheckpoint('query_api_loop_start')
@@ -1102,6 +1137,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { transition: {
reason: 'collapse_drain_retry', reason: 'collapse_drain_retry',
committed: drained.committed, committed: drained.committed,
@@ -1155,6 +1191,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'reactive_compact_retry' }, transition: { reason: 'reactive_compact_retry' },
} }
state = next state = next
@@ -1210,6 +1247,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'max_output_tokens_escalate' }, transition: { reason: 'max_output_tokens_escalate' },
} }
state = next state = next
@@ -1238,6 +1276,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { transition: {
reason: 'max_output_tokens_recovery', reason: 'max_output_tokens_recovery',
attempt: maxOutputTokensRecoveryCount + 1, attempt: maxOutputTokensRecoveryCount + 1,
@@ -1295,6 +1334,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: true, stopHookActive: true,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'stop_hook_blocking' }, transition: { reason: 'stop_hook_blocking' },
} }
state = next state = next
@@ -1331,6 +1371,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'token_budget_continuation' }, transition: { reason: 'token_budget_continuation' },
} }
continue continue
@@ -1350,6 +1391,77 @@ async function* queryLoop(
} }
} }
// Continuation nudge: detect when the model signals intent to continue
// (e.g., "so now I have to do it", "let me now...", "I'll need to...")
// but returned no tool calls. This prevents premature task completion.
//
// Guard: capped at MAX_CONTINUATION_NUDGES to prevent infinite loops
// when the model keeps matching signals without ever calling tools.
if (
assistantMessages.length > 0 &&
turnCount < (maxTurns ?? Infinity) &&
state.continuationNudgeCount < MAX_CONTINUATION_NUDGES
) {
const lastAssistant = assistantMessages.at(-1)
if (lastAssistant?.type === 'assistant') {
const lastText = lastAssistant.message.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map(b => b.text)
.join(' ')
.toLowerCase()
// Tightened patterns: require explicit action verbs and exclude
// common explanatory phrasing to reduce false positives.
const continuationSignals = [
// Only match "so now I/let me/we" followed by an action verb
/\bso now (i|let me|we) (need to|have to|should|must|will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up)\b/,
// "now I'll" + action (not "now I'll explain" etc.)
/\bnow i('ll| will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed)\b/,
// "let me" + action (not "let me think/explain/show")
/\blet me (go ahead and |now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|proceed)\b/,
// "I'll/I need to/I have to" + action, only if message is short (<80 chars)
...(lastText.length < 80
? [/\b(i('ll| will| need to| have to| must) (now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up))\b/]
: []),
// "time to" + action
/\btime to (do|create|write|edit|update|fix|implement|add|run|check|make|build|get started|begin)\b/,
// "next, I'll/let me" + action, only if message is short
...(lastText.length < 80
? [/\bnext,?\s+(i('ll| will)|let me|i need to) (do|create|write|edit|update|fix|implement|add|run|check|make|build)\b/]
: []),
]
// Don't nudge if the text contains completion markers
const completionMarkers = /\b(done|finished|completed|complete|summary|that's all|that is all|all set|hope this helps|let me know if)\b/
if (completionMarkers.test(lastText)) {
// Model signaled completion — don't nudge
} else if (continuationSignals.some(re => re.test(lastText))) {
logForDebugging(
`Continuation nudge triggered (${state.continuationNudgeCount + 1}/${MAX_CONTINUATION_NUDGES}): model said "${lastText.slice(-120)}" without tool calls`,
)
const nudge = createUserMessage({
content: 'Continue with the task. Use the appropriate tools to proceed.',
isMeta: true,
})
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, nudge],
toolUseContext,
autoCompactTracking: tracking,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
maxOutputTokensOverride: undefined,
pendingToolUseSummary: undefined,
stopHookActive: undefined,
turnCount,
continuationNudgeCount: state.continuationNudgeCount + 1,
transition: { reason: 'continuation_nudge' },
}
state = next
continue
}
}
}
return { reason: 'completed' } return { reason: 'completed' }
} }
@@ -1715,6 +1827,7 @@ async function* queryLoop(
turnCount: nextTurnCount, turnCount: nextTurnCount,
maxOutputTokensRecoveryCount: 0, maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false, hasAttemptedReactiveCompact: false,
continuationNudgeCount: 0,
pendingToolUseSummary: nextPendingToolUseSummary, pendingToolUseSummary: nextPendingToolUseSummary,
maxOutputTokensOverride: undefined, maxOutputTokensOverride: undefined,
stopHookActive, stopHookActive,

View File

@@ -196,7 +196,7 @@ const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => { };
const PROACTIVE_FALSE = () => false; const PROACTIVE_FALSE = () => false;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null; const useScheduledTasks = require('../hooks/useScheduledTasks.js').useScheduledTasks;
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
@@ -3873,7 +3873,7 @@ export function REPL({
// empty to non-empty, not on every length change -- otherwise a render loop // empty to non-empty, not on every length change -- otherwise a render loop
// (concurrent onQuery thrashing, etc.) spams saveGlobalConfig, which hits // (concurrent onQuery thrashing, etc.) spams saveGlobalConfig, which hits
// ELOCKED under concurrent sessions and falls back to unlocked writes. // ELOCKED under concurrent sessions and falls back to unlocked writes.
// That write storm is the primary trigger for ~/.claude.json corruption // That write storm is the primary trigger for ~/.openclaude.json corruption
// (GH #3117). // (GH #3117).
const hasCountedQueueUseRef = useRef(false); const hasCountedQueueUseRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -4076,21 +4076,13 @@ export function REPL({
}); });
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
if (feature('AGENT_TRIGGERS')) { // and session-only /loop runs.
// Assistant mode bypasses the isLoading gate (the proactive tick →
// Sleep → tick loop would otherwise starve the scheduler).
// kairosEnabled is set once in initialState (main.tsx) and never mutated — no
// subscription needed. The tengu_kairos_cron runtime gate is checked inside
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
// condition would break rules-of-hooks.
const assistantMode = store.getState().kairosEnabled; const assistantMode = store.getState().kairosEnabled;
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useScheduledTasks({
useScheduledTasks!({
isLoading, isLoading,
assistantMode, assistantMode,
setMessages setMessages
}); });
}
// Note: Permission polling is now handled by useInboxPoller // Note: Permission polling is now handled by useInboxPoller
// - Workers receive permission responses via mailbox messages // - Workers receive permission responses via mailbox messages

View File

@@ -334,7 +334,7 @@ async function processRemoteEvalPayload(
// Empty object is truthy — without the length check, `{features: {}}` // Empty object is truthy — without the length check, `{features: {}}`
// (transient server bug, truncated response) would pass, clear the maps // (transient server bug, truncated response) would pass, clear the maps
// below, return true, and syncRemoteEvalToDisk would wholesale-write `{}` // below, return true, and syncRemoteEvalToDisk would wholesale-write `{}`
// to disk: total flag blackout for every process sharing ~/.claude.json. // to disk: total flag blackout for every process sharing ~/.openclaude.json.
if (!payload?.features || Object.keys(payload.features).length === 0) { if (!payload?.features || Object.keys(payload.features).length === 0) {
return false return false
} }

View File

@@ -116,9 +116,21 @@ async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
return parsed.data return parsed.data
}) })
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status ?? 'no-response'
const code = error.code ?? 'unknown-code'
const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'
const requestUrl = error.config?.url ?? 'unknown-url'
const message = error.message ?? 'unknown axios error'
logForDebugging( logForDebugging(
`[Bootstrap] Fetch failed: ${axios.isAxiosError(error) ? (error.response?.status ?? error.code) : 'unknown'}`, `[Bootstrap] Fetch failed: status=${status} code=${code} method=${method} url=${requestUrl} message=${message}`,
) )
} else {
const message = error instanceof Error ? error.message : String(error)
logForDebugging(`[Bootstrap] Fetch failed: ${message}`)
}
throw error throw error
} }
} }

View File

@@ -23,6 +23,7 @@ import { randomUUID } from 'crypto'
import { import {
getAPIProvider, getAPIProvider,
isFirstPartyAnthropicBaseUrl, isFirstPartyAnthropicBaseUrl,
isGithubNativeAnthropicMode,
} from 'src/utils/model/providers.js' } from 'src/utils/model/providers.js'
import { import {
getAttributionHeader, getAttributionHeader,
@@ -334,8 +335,13 @@ export function getPromptCachingEnabled(model: string): boolean {
// Prompt caching is an Anthropic-specific feature. Third-party providers // Prompt caching is an Anthropic-specific feature. Third-party providers
// do not understand cache_control blocks and strict backends (e.g. Azure // do not understand cache_control blocks and strict backends (e.g. Azure
// Foundry) reject or flag requests that contain them. // Foundry) reject or flag requests that contain them.
//
// Exception: when the GitHub provider is configured in native Anthropic API
// mode (CLAUDE_CODE_GITHUB_ANTHROPIC_API=1), requests are sent in Anthropic
// format, so cache_control blocks are supported.
const provider = getAPIProvider() const provider = getAPIProvider()
if (provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex') { const isNativeGithub = isGithubNativeAnthropicMode(model)
if (provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && !isNativeGithub) {
return false return false
} }

View File

@@ -14,6 +14,7 @@ import { getSmallFastModel } from 'src/utils/model/model.js'
import { import {
getAPIProvider, getAPIProvider,
isFirstPartyAnthropicBaseUrl, isFirstPartyAnthropicBaseUrl,
isGithubNativeAnthropicMode,
} from 'src/utils/model/providers.js' } from 'src/utils/model/providers.js'
import { getProxyFetchOptions } from 'src/utils/proxy.js' import { getProxyFetchOptions } from 'src/utils/proxy.js'
import { import {
@@ -174,6 +175,25 @@ export async function getAnthropicClient({
providerOverride, providerOverride,
}) as unknown as Anthropic }) as unknown as Anthropic
} }
// GitHub provider in native Anthropic API mode: send requests in Anthropic
// format so cache_control blocks are honoured and prompt caching works.
// Requires the GitHub endpoint (OPENAI_BASE_URL) to support Anthropic's
// messages API — set CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 to opt in.
if (isGithubNativeAnthropicMode(model)) {
const githubBaseUrl =
process.env.OPENAI_BASE_URL?.replace(/\/$/, '') ??
'https://api.githubcopilot.com'
const githubToken =
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
const nativeArgs: ConstructorParameters<typeof Anthropic>[0] = {
...ARGS,
baseURL: githubBaseUrl,
authToken: githubToken,
// No apiKey — we authenticate via Bearer token (authToken)
apiKey: null,
}
return new Anthropic(nativeArgs)
}
if ( if (
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) || isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) ||

View File

@@ -0,0 +1,166 @@
import { createServer } from 'node:http'
import { afterEach, expect, mock, test } from 'bun:test'
import { CodexOAuthService } from './codexOAuth.js'
const originalFetch = globalThis.fetch
const originalCallbackPort = process.env.CODEX_OAUTH_CALLBACK_PORT
const originalClientId = process.env.CODEX_OAUTH_CLIENT_ID
afterEach(() => {
mock.restore()
globalThis.fetch = originalFetch
if (originalCallbackPort === undefined) {
delete process.env.CODEX_OAUTH_CALLBACK_PORT
} else {
process.env.CODEX_OAUTH_CALLBACK_PORT = originalCallbackPort
}
if (originalClientId === undefined) {
delete process.env.CODEX_OAUTH_CLIENT_ID
} else {
process.env.CODEX_OAUTH_CLIENT_ID = originalClientId
}
})
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate test port.')))
return
}
const { port } = address
server.close(error => {
if (error) {
reject(error)
return
}
resolve(port)
})
})
})
}
function buildCallbackRequest(authUrl: string): string {
const authorizeUrl = new URL(authUrl)
const redirectUri = authorizeUrl.searchParams.get('redirect_uri')
const state = authorizeUrl.searchParams.get('state')
if (!redirectUri || !state) {
throw new Error('Codex OAuth test did not receive a valid authorization URL.')
}
const callbackUrl = new URL(redirectUri)
callbackUrl.searchParams.set('code', 'auth-code')
callbackUrl.searchParams.set('state', state)
return callbackUrl.toString()
}
test('serves updated success copy after a successful Codex OAuth flow', async () => {
const callbackPort = await getFreePort()
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
globalThis.fetch = mock(async (input, init) => {
const url = String(input)
if (url.startsWith('http://localhost:')) {
return originalFetch(input, init)
}
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
}) as typeof fetch
const service = new CodexOAuthService()
let callbackResponsePromise!: Promise<Response>
const flowPromise = service.startOAuthFlow(async authUrl => {
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
})
const tokens = await flowPromise
const callbackResponse = await callbackResponsePromise
const html = await callbackResponse.text()
expect(tokens.accessToken).toBe('access-token')
expect(tokens.refreshToken).toBe('refresh-token')
expect(html).toContain('You can return to OpenClaude now.')
expect(html).toContain(
'OpenClaude will finish activating your new Codex OAuth login.',
)
expect(html).not.toContain('continue automatically')
})
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
const callbackPort = await getFreePort()
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
let resolveFetchStart!: () => void
const fetchStarted = new Promise<void>(resolve => {
resolveFetchStart = resolve
})
globalThis.fetch = mock((input, init) => {
const url = String(input)
if (url.startsWith('http://localhost:')) {
return originalFetch(input, init)
}
return new Promise<Response>((_resolve, reject) => {
resolveFetchStart()
const signal = init?.signal
if (!signal) {
return
}
if (signal.aborted) {
reject(signal.reason)
return
}
signal.addEventListener(
'abort',
() => {
reject(signal.reason)
},
{ once: true },
)
})
}) as typeof fetch
const service = new CodexOAuthService()
let callbackResponsePromise!: Promise<Response>
const flowPromise = service.startOAuthFlow(async authUrl => {
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
})
await fetchStarted
service.cleanup()
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
const callbackResponse = await callbackResponsePromise
const html = await callbackResponse.text()
expect(html).toContain('Codex login cancelled')
expect(html).toContain('retry in OpenClaude')
})

View File

@@ -0,0 +1,307 @@
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from '../oauth/crypto.js'
import {
asTrimmedString,
CODEX_OAUTH_ISSUER,
CODEX_OAUTH_ORIGINATOR,
CODEX_OAUTH_SCOPE,
escapeHtml,
exchangeCodexIdTokenForApiKey,
getCodexOAuthCallbackPort,
getCodexOAuthClientId,
parseChatgptAccountId,
} from './codexOAuthShared.js'
type CodexOAuthTokenResponse = {
id_token?: string
access_token?: string
refresh_token?: string
}
export type CodexOAuthTokens = {
apiKey?: string
accessToken: string
refreshToken: string
idToken?: string
accountId?: string
}
function buildCodexAuthorizeUrl(options: {
port: number
codeChallenge: string
state: string
}): string {
const redirectUri = `http://localhost:${options.port}/auth/callback`
const authUrl = new URL(`${CODEX_OAUTH_ISSUER}/oauth/authorize`)
authUrl.searchParams.append('response_type', 'code')
authUrl.searchParams.append('client_id', getCodexOAuthClientId())
authUrl.searchParams.append('redirect_uri', redirectUri)
authUrl.searchParams.append('scope', CODEX_OAUTH_SCOPE)
authUrl.searchParams.append('code_challenge', options.codeChallenge)
authUrl.searchParams.append('code_challenge_method', 'S256')
authUrl.searchParams.append('id_token_add_organizations', 'true')
authUrl.searchParams.append('codex_cli_simplified_flow', 'true')
authUrl.searchParams.append('state', options.state)
authUrl.searchParams.append('originator', CODEX_OAUTH_ORIGINATOR)
return authUrl.toString()
}
function renderSuccessPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Complete</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login complete</h1>
<p>You can return to OpenClaude now.</p>
<p>OpenClaude will finish activating your new Codex OAuth login.</p>
</body>
</html>`
}
function renderErrorPage(message: string): string {
const safeMessage = escapeHtml(message)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Failed</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; color: #991b1b; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login failed</h1>
<p>${safeMessage}</p>
<p>You can close this window and try again in OpenClaude.</p>
</body>
</html>`
}
function renderCancelledPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Cancelled</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login cancelled</h1>
<p>You can close this window and retry in OpenClaude.</p>
</body>
</html>`
}
async function exchangeAuthorizationCode(options: {
authorizationCode: string
codeVerifier: string
port: number
signal?: AbortSignal
}): Promise<CodexOAuthTokens> {
const redirectUri = `http://localhost:${options.port}/auth/callback`
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: options.authorizationCode,
redirect_uri: redirectUri,
client_id: getCodexOAuthClientId(),
code_verifier: options.codeVerifier,
})
const response = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(
errorText.trim()
? `Codex OAuth token exchange failed (${response.status}): ${errorText.trim()}`
: `Codex OAuth token exchange failed with status ${response.status}.`,
)
}
const payload = (await response.json()) as CodexOAuthTokenResponse
const accessToken = asTrimmedString(payload.access_token)
const refreshToken = asTrimmedString(payload.refresh_token)
if (!accessToken || !refreshToken) {
throw new Error(
'Codex OAuth completed, but the token response was missing credentials.',
)
}
const idToken = asTrimmedString(payload.id_token)
const apiKey = idToken
? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined)
: undefined
return {
apiKey,
accessToken,
refreshToken,
idToken,
accountId:
parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken),
}
}
export class CodexOAuthService {
private authCodeListener: AuthCodeListener | null = null
private port: number | null = null
private tokenExchangeAbortController: AbortController | null = null
private buildCancellationError(): Error {
return new Error('Codex OAuth flow was cancelled.')
}
async startOAuthFlow(
authURLHandler: (authUrl: string) => Promise<void>,
): Promise<CodexOAuthTokens> {
const codeVerifier = generateCodeVerifier()
const callbackPort = getCodexOAuthCallbackPort()
const authCodeListener = new AuthCodeListener('/auth/callback')
this.authCodeListener = authCodeListener
this.port = null
try {
const port = await authCodeListener.start(callbackPort)
this.port = port
const state = generateState()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const authUrl = buildCodexAuthorizeUrl({
port,
codeChallenge,
state,
})
try {
const authorizationCode = await authCodeListener.waitForAuthorization(
state,
async () => {
await authURLHandler(authUrl)
},
)
const tokenExchangeAbortController = new AbortController()
this.tokenExchangeAbortController = tokenExchangeAbortController
let tokens: CodexOAuthTokens
try {
tokens = await exchangeAuthorizationCode({
authorizationCode,
codeVerifier,
port,
signal: tokenExchangeAbortController.signal,
})
} finally {
if (
this.tokenExchangeAbortController === tokenExchangeAbortController
) {
this.tokenExchangeAbortController = null
}
}
if (this.authCodeListener !== authCodeListener) {
throw this.buildCancellationError()
}
authCodeListener.handleSuccessRedirect([], res => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(renderSuccessPage())
})
return tokens
} catch (error) {
const resolvedError =
this.authCodeListener === authCodeListener
? error
: this.buildCancellationError()
if (authCodeListener.hasPendingResponse()) {
const isCancellation =
resolvedError instanceof Error &&
resolvedError.message === 'Codex OAuth flow was cancelled.'
authCodeListener.handleErrorRedirect(res => {
res.writeHead(isCancellation ? 200 : 400, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(
isCancellation
? renderCancelledPage()
: renderErrorPage(
resolvedError instanceof Error
? resolvedError.message
: String(resolvedError),
),
)
})
}
throw resolvedError
} finally {
this.cleanup()
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (
message.includes('EADDRINUSE') ||
message.includes(String(callbackPort))
) {
throw new Error(
`Codex OAuth needs localhost:${callbackPort} for its callback. Close any app already using that port and try again.`,
)
}
throw error
}
}
cleanup(): void {
const cancellationError = this.buildCancellationError()
this.tokenExchangeAbortController?.abort(cancellationError)
this.tokenExchangeAbortController = null
if (this.authCodeListener?.hasPendingResponse()) {
this.authCodeListener.handleErrorRedirect(res => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(renderCancelledPage())
})
}
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
this.authCodeListener = null
this.port = null
}
}

View File

@@ -0,0 +1,139 @@
export const CODEX_OAUTH_ISSUER = 'https://auth.openai.com'
export const CODEX_REFRESH_URL = `${CODEX_OAUTH_ISSUER}/oauth/token`
export const DEFAULT_CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455
export const CODEX_OAUTH_SCOPE =
'openid profile email offline_access api.connectors.read api.connectors.invoke'
export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs'
export const CODEX_API_KEY_TOKEN_NAME = 'openai-api-key'
export const CODEX_ID_TOKEN_SUBJECT_TYPE =
'urn:ietf:params:oauth:token-type:id_token'
export const CODEX_TOKEN_EXCHANGE_GRANT =
'urn:ietf:params:oauth:grant-type:token-exchange'
export function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
export function getCodexOAuthClientId(
env: NodeJS.ProcessEnv = process.env,
): string {
return asTrimmedString(env.CODEX_OAUTH_CLIENT_ID) ?? DEFAULT_CODEX_OAUTH_CLIENT_ID
}
export function getCodexOAuthCallbackPort(
env: NodeJS.ProcessEnv = process.env,
): number {
const rawPort = asTrimmedString(env.CODEX_OAUTH_CALLBACK_PORT)
if (!rawPort) {
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
}
const parsed = Number.parseInt(rawPort, 10)
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
return parsed
}
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
}
export function decodeJwtPayload(
token: string,
): Record<string, unknown> | undefined {
const parts = token.split('.')
if (parts.length < 2) return undefined
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const json = Buffer.from(padded, 'base64').toString('utf8')
const parsed = JSON.parse(json)
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
export function parseChatgptAccountId(
token: string | undefined,
): string | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const nestedAuth =
payload?.['https://api.openai.com/auth'] &&
typeof payload['https://api.openai.com/auth'] === 'object'
? (payload['https://api.openai.com/auth'] as Record<string, unknown>)
: undefined
return (
asTrimmedString(
nestedAuth?.chatgpt_account_id ??
payload?.['https://api.openai.com/auth.chatgpt_account_id'] ??
payload?.chatgpt_account_id,
) ?? undefined
)
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, char => {
switch (char) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case '\'':
return '&#39;'
default:
return char
}
})
}
export async function exchangeCodexIdTokenForApiKey(
idToken: string,
): Promise<string> {
const body = new URLSearchParams({
grant_type: CODEX_TOKEN_EXCHANGE_GRANT,
client_id: getCodexOAuthClientId(),
requested_token: CODEX_API_KEY_TOKEN_NAME,
subject_token: idToken,
subject_token_type: CODEX_ID_TOKEN_SUBJECT_TYPE,
})
const response = await fetch(CODEX_REFRESH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const bodyText = await response.text().catch(() => '')
throw new Error(
bodyText.trim()
? `Codex API key exchange failed (${response.status}): ${bodyText.trim()}`
: `Codex API key exchange failed with status ${response.status}.`,
)
}
const payload = (await response.json()) as { access_token?: string }
const apiKey = asTrimmedString(payload.access_token)
if (!apiKey) {
throw new Error(
'Codex API key exchange completed, but no API key token was returned.',
)
}
return apiKey
}

View File

@@ -8,16 +8,13 @@ import {
convertCodexResponseToAnthropicMessage, convertCodexResponseToAnthropicMessage,
convertToolsToResponsesTools, convertToolsToResponsesTools,
} from './codexShim.js' } from './codexShim.js'
import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
const tempDirs: string[] = [] const tempDirs: string[] = []
const originalEnv = { const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_BASE: process.env.OPENAI_API_BASE,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
OPENAI_MODEL: process.env.OPENAI_MODEL,
} }
afterEach(() => { afterEach(() => {
@@ -30,6 +27,9 @@ afterEach(() => {
if (originalEnv.CLAUDE_CODE_USE_GITHUB === undefined) delete process.env.CLAUDE_CODE_USE_GITHUB if (originalEnv.CLAUDE_CODE_USE_GITHUB === undefined) delete process.env.CLAUDE_CODE_USE_GITHUB
else process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB else process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
while (tempDirs.length > 0) { while (tempDirs.length > 0) {
const dir = tempDirs.pop() const dir = tempDirs.pop()
if (dir) rmSync(dir, { recursive: true, force: true }) if (dir) rmSync(dir, { recursive: true, force: true })
@@ -59,6 +59,10 @@ async function collectStreamEventTypes(responseText: string): Promise<string[]>
return events return events
} }
async function importFreshProviderConfigModule() {
return import(`./providerConfig.js?ts=${Date.now()}-${Math.random()}`)
}
describe('Codex provider config', () => { describe('Codex provider config', () => {
const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL
const originalOpenaiApiBase = process.env.OPENAI_API_BASE const originalOpenaiApiBase = process.env.OPENAI_API_BASE
@@ -75,7 +79,8 @@ describe('Codex provider config', () => {
else process.env.OPENAI_API_BASE = originalOpenaiApiBase else process.env.OPENAI_API_BASE = originalOpenaiApiBase
}) })
test('resolves codexplan alias to Codex transport with reasoning', () => { test('resolves codexplan alias to Codex transport with reasoning', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -84,9 +89,23 @@ describe('Codex provider config', () => {
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
expect(resolved.resolvedModel).toBe('gpt-5.4') expect(resolved.resolvedModel).toBe('gpt-5.4')
expect(resolved.reasoning).toEqual({ effort: 'high' }) expect(resolved.reasoning).toEqual({ effort: 'high' })
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
}) })
test('does not force Codex transport when a local non-Codex base URL is explicit', () => { test('resolves codexspark alias to Codex transport with Codex base URL', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest({ model: 'codexspark' })
expect(resolved.transport).toBe('codex_responses')
expect(resolved.resolvedModel).toBe('gpt-5.3-codex-spark')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
})
test('does not force Codex transport when a local non-Codex base URL is explicit', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
const resolved = resolveProviderRequest({ const resolved = resolveProviderRequest({
model: 'codexplan', model: 'codexplan',
baseUrl: 'http://127.0.0.1:8080/v1', baseUrl: 'http://127.0.0.1:8080/v1',
@@ -97,7 +116,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-5.4') expect(resolved.resolvedModel).toBe('gpt-5.4')
}) })
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', () => { test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
// On Windows, env vars can leak as the literal string "undefined" instead of // On Windows, env vars can leak as the literal string "undefined" instead of
// the JS value undefined when not properly unset (issue #336). // the JS value undefined when not properly unset (issue #336).
process.env.OPENAI_BASE_URL = 'undefined' process.env.OPENAI_BASE_URL = 'undefined'
@@ -105,20 +125,57 @@ describe('Codex provider config', () => {
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
}) })
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', () => { test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_BASE_URL = '' process.env.OPENAI_BASE_URL = ''
const resolved = resolveProviderRequest({ model: 'codexplan' }) const resolved = resolveProviderRequest({ model: 'codexplan' })
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
}) })
test('prefers explicit baseUrl option over env var', () => { test('prefers explicit baseUrl option over env var', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_BASE_URL = 'https://example.com/v1' process.env.OPENAI_BASE_URL = 'https://example.com/v1'
const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' }) const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' })
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex') expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
}) })
test('loads Codex credentials from auth.json fallback', () => { test('default gpt-4o uses OpenAI base URL (no regression)', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest({ model: 'gpt-4o' })
expect(resolved.transport).toBe('chat_completions')
expect(resolved.baseUrl).toBe('https://api.openai.com/v1')
expect(resolved.resolvedModel).toBe('gpt-4o')
})
test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_MODEL = 'codexplan'
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest()
expect(resolved.transport).toBe('codex_responses')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
expect(resolved.resolvedModel).toBe('gpt-5.4')
})
test('does not override custom base URL for codexplan (e.g., local provider)', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_MODEL = 'codexplan'
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest()
expect(resolved.transport).toBe('chat_completions')
expect(resolved.baseUrl).toBe('http://localhost:11434/v1')
})
test('loads Codex credentials from auth.json fallback', async () => {
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
const authPath = createTempAuthJson({ const authPath = createTempAuthJson({
tokens: { tokens: {
access_token: 'header.payload.signature', access_token: 'header.payload.signature',
@@ -134,6 +191,31 @@ describe('Codex provider config', () => {
expect(credentials.accountId).toBe('acct_test') expect(credentials.accountId).toBe('acct_test')
expect(credentials.source).toBe('auth.json') expect(credentials.source).toBe('auth.json')
}) })
test('does not treat auth.json id_token as a Codex bearer credential', async () => {
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
const idTokenPayload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_from_id_token',
},
}),
'utf8',
).toString('base64url')
const authPath = createTempAuthJson({
tokens: {
id_token: `header.${idTokenPayload}.signature`,
},
})
const credentials = resolveCodexApiCredentials({
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('')
expect(credentials.accountId).toBe('acct_from_id_token')
expect(credentials.source).toBe('none')
})
}) })
describe('Codex request translation', () => { describe('Codex request translation', () => {
@@ -465,7 +547,7 @@ describe('Codex request translation', () => {
]) ])
}) })
test('strips leaked reasoning preamble from completed Codex text responses', () => { test('strips <think> tag block from completed Codex text responses', () => {
const message = convertCodexResponseToAnthropicMessage( const message = convertCodexResponseToAnthropicMessage(
{ {
id: 'resp_1', id: 'resp_1',
@@ -478,7 +560,7 @@ describe('Codex request translation', () => {
{ {
type: 'output_text', type: 'output_text',
text: text:
'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?', '<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?',
}, },
], ],
}, },
@@ -496,6 +578,37 @@ describe('Codex request translation', () => {
]) ])
}) })
test('strips unterminated <think> tag at block boundary in Codex completed response', () => {
const message = convertCodexResponseToAnthropicMessage(
{
id: 'resp_1',
model: 'gpt-5.4',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text:
'Here is the answer.\n<think>wait, let me reconsider the user request',
},
],
},
],
usage: { input_tokens: 12, output_tokens: 4 },
},
'gpt-5.4',
)
expect(message.content).toEqual([
{
type: 'text',
text: 'Here is the answer.',
},
])
})
test('translates Codex SSE text stream into Anthropic events', async () => { test('translates Codex SSE text stream into Anthropic events', async () => {
const responseText = [ const responseText = [
'event: response.output_item.added', 'event: response.output_item.added',
@@ -527,7 +640,7 @@ describe('Codex request translation', () => {
]) ])
}) })
test('strips leaked reasoning preamble from Codex SSE text stream', async () => { test('strips <think> tag block from Codex SSE text stream', async () => {
const responseText = [ const responseText = [
'event: response.output_item.added', 'event: response.output_item.added',
'data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":0}', 'data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":0}',
@@ -536,13 +649,13 @@ describe('Codex request translation', () => {
'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_1","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":1}', 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_1","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":1}',
'', '',
'event: response.output_text.delta', 'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","content_index":0,"delta":"The user just said \\"hey\\" - a simple greeting. I should respond briefly and friendly.\\n\\nHey! How can I help you today?","item_id":"msg_1","output_index":0,"sequence_number":2}', 'data: {"type":"response.output_text.delta","content_index":0,"delta":"<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?","item_id":"msg_1","output_index":0,"sequence_number":2}',
'', '',
'event: response.output_item.done', 'event: response.output_item.done',
'data: {"type":"response.output_item.done","item":{"id":"msg_1","type":"message","status":"completed","content":[{"type":"output_text","text":"The user just said \\"hey\\" - a simple greeting. I should respond briefly and friendly.\\n\\nHey! How can I help you today?"}],"role":"assistant"},"output_index":0,"sequence_number":3}', 'data: {"type":"response.output_item.done","item":{"id":"msg_1","type":"message","status":"completed","content":[{"type":"output_text","text":"<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?"}],"role":"assistant"},"output_index":0,"sequence_number":3}',
'', '',
'event: response.completed', 'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The user just said \\"hey\\" - a simple greeting. I should respond briefly and friendly.\\n\\nHey! How can I help you today?"}]}],"usage":{"input_tokens":2,"output_tokens":1}},"sequence_number":4}', 'data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?"}]}],"usage":{"input_tokens":2,"output_tokens":1}},"sequence_number":4}',
'', '',
].join('\n') ].join('\n')
@@ -564,6 +677,50 @@ describe('Codex request translation', () => {
} }
} }
expect(textDeltas).toEqual(['Hey! How can I help you today?']) expect(textDeltas.join('')).toBe('Hey! How can I help you today?')
})
test('preserves prose without tags (no phrase-based false positive)', async () => {
// Regression test: older phrase-based sanitizer would incorrectly strip text
// starting with "I should" or "The user". The tag-based approach leaves it alone.
const responseText = [
'event: response.output_item.added',
'data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":0}',
'',
'event: response.content_part.added',
'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_1","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":1}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","content_index":0,"delta":"I should note that the user role requires a briefly concise friendly response format.","item_id":"msg_1","output_index":0,"sequence_number":2}',
'',
'event: response.output_item.done',
'data: {"type":"response.output_item.done","item":{"id":"msg_1","type":"message","status":"completed","content":[{"type":"output_text","text":"I should note that the user role requires a briefly concise friendly response format."}],"role":"assistant"},"output_index":0,"sequence_number":3}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I should note that the user role requires a briefly concise friendly response format."}]}],"usage":{"input_tokens":2,"output_tokens":1}},"sequence_number":4}',
'',
].join('\n')
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseText))
controller.close()
},
})
const textDeltas: string[] = []
for await (const event of codexStreamToAnthropic(
new Response(stream),
'gpt-5.4',
)) {
const delta = (event as { delta?: { type?: string; text?: string } }).delta
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
textDeltas.push(delta.text)
}
}
expect(textDeltas.join('')).toBe(
'I should note that the user role requires a briefly concise friendly response format.',
)
}) })
}) })

View File

@@ -1,14 +1,14 @@
import { APIError } from '@anthropic-ai/sdk' import { APIError } from '@anthropic-ai/sdk'
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
import type { import type {
ResolvedCodexCredentials, ResolvedCodexCredentials,
ResolvedProviderRequest, ResolvedProviderRequest,
} from './providerConfig.js' } from './providerConfig.js'
import { sanitizeSchemaForOpenAICompat } from './openaiSchemaSanitizer.js' import { sanitizeSchemaForOpenAICompat } from './openaiSchemaSanitizer.js'
import { import {
looksLikeLeakedReasoningPrefix, createThinkTagFilter,
shouldBufferPotentialReasoningPrefix, stripThinkTags,
stripLeakedReasoningPreamble, } from './thinkTagSanitizer.js'
} from './reasoningLeakSanitizer.js'
export interface AnthropicUsage { export interface AnthropicUsage {
input_tokens: number input_tokens: number
@@ -559,12 +559,15 @@ export async function performCodexRequest(options: {
} }
headers.originator ??= 'openclaude' headers.originator ??= 'openclaude'
const response = await fetch(`${options.request.baseUrl}/responses`, { const response = await fetchWithProxyRetry(
`${options.request.baseUrl}/responses`,
{
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
signal: options.signal, signal: options.signal,
}) },
)
if (!response.ok) { if (!response.ok) {
const errorBody = await response.text().catch(() => 'unknown error') const errorBody = await response.text().catch(() => 'unknown error')
@@ -580,15 +583,55 @@ export async function performCodexRequest(options: {
return response return response
} }
async function* readSseEvents(response: Response): AsyncGenerator<CodexSseEvent> { async function* readSseEvents(response: Response, signal?: AbortSignal): AsyncGenerator<CodexSseEvent> {
const reader = response.body?.getReader() const reader = response.body?.getReader()
if (!reader) return if (!reader) return
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
const STREAM_IDLE_TIMEOUT_MS = 120_000 // 2 minutes without data
let lastDataTime = Date.now()
/**
* Read from the stream with an idle timeout. Respects the caller's
* AbortSignal — clears the idle timer on abort so the AbortError
* surfaces cleanly instead of a spurious idle timeout.
*/
async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array>> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
const elapsed = Math.round((Date.now() - lastDataTime) / 1000)
reject(new Error(
`Codex SSE stream idle for ${elapsed}s (limit: ${STREAM_IDLE_TIMEOUT_MS / 1000}s). Connection likely dropped.`,
))
}, STREAM_IDLE_TIMEOUT_MS)
let abortCleanup: (() => void) | undefined
if (signal) {
abortCleanup = () => {
clearTimeout(timeoutId)
}
signal.addEventListener('abort', abortCleanup, { once: true })
}
reader.read().then(
result => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
if (result.value) lastDataTime = Date.now()
resolve(result)
},
err => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
reject(err)
},
)
})
}
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await readWithTimeout()
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
@@ -649,10 +692,11 @@ function determineStopReason(
export async function collectCodexCompletedResponse( export async function collectCodexCompletedResponse(
response: Response, response: Response,
signal?: AbortSignal,
): Promise<Record<string, any>> { ): Promise<Record<string, any>> {
let completedResponse: Record<string, any> | undefined let completedResponse: Record<string, any> | undefined
for await (const event of readSseEvents(response)) { for await (const event of readSseEvents(response, signal)) {
if (event.event === 'response.failed') { if (event.event === 'response.failed') {
const msg = event.data?.response?.error?.message ?? const msg = event.data?.response?.error?.message ??
event.data?.error?.message ?? 'Codex response failed' event.data?.error?.message ?? 'Codex response failed'
@@ -681,6 +725,7 @@ export async function collectCodexCompletedResponse(
export async function* codexStreamToAnthropic( export async function* codexStreamToAnthropic(
response: Response, response: Response,
model: string, model: string,
signal?: AbortSignal,
): AsyncGenerator<AnthropicStreamEvent> { ): AsyncGenerator<AnthropicStreamEvent> {
const messageId = makeMessageId() const messageId = makeMessageId()
const toolBlocksByItemId = new Map< const toolBlocksByItemId = new Map<
@@ -688,34 +733,29 @@ export async function* codexStreamToAnthropic(
{ index: number; toolUseId: string } { index: number; toolUseId: string }
>() >()
let activeTextBlockIndex: number | null = null let activeTextBlockIndex: number | null = null
let activeTextBuffer = '' const thinkFilter = createThinkTagFilter()
let textBufferMode: 'none' | 'pending' | 'strip' = 'none'
let nextContentBlockIndex = 0 let nextContentBlockIndex = 0
let sawToolUse = false let sawToolUse = false
let finalResponse: Record<string, any> | undefined let finalResponse: Record<string, any> | undefined
const closeActiveTextBlock = async function* () { const closeActiveTextBlock = async function* () {
if (activeTextBlockIndex === null) return if (activeTextBlockIndex === null) return
if (textBufferMode !== 'none') { const tail = thinkFilter.flush()
const sanitized = stripLeakedReasoningPreamble(activeTextBuffer) if (tail) {
if (sanitized) {
yield { yield {
type: 'content_block_delta', type: 'content_block_delta',
index: activeTextBlockIndex, index: activeTextBlockIndex,
delta: { delta: {
type: 'text_delta', type: 'text_delta',
text: sanitized, text: tail,
}, },
} }
} }
}
yield { yield {
type: 'content_block_stop', type: 'content_block_stop',
index: activeTextBlockIndex, index: activeTextBlockIndex,
} }
activeTextBlockIndex = null activeTextBlockIndex = null
activeTextBuffer = ''
textBufferMode = 'none'
} }
const startTextBlockIfNeeded = async function* () { const startTextBlockIfNeeded = async function* () {
@@ -742,7 +782,7 @@ export async function* codexStreamToAnthropic(
}, },
} }
for await (const event of readSseEvents(response)) { for await (const event of readSseEvents(response, signal)) {
const payload = event.data const payload = event.data
if (event.event === 'response.output_item.added') { if (event.event === 'response.output_item.added') {
@@ -791,43 +831,17 @@ export async function* codexStreamToAnthropic(
if (event.event === 'response.output_text.delta') { if (event.event === 'response.output_text.delta') {
yield* startTextBlockIfNeeded() yield* startTextBlockIfNeeded()
activeTextBuffer += payload.delta ?? ''
if (activeTextBlockIndex !== null) { if (activeTextBlockIndex !== null) {
if ( const visible = thinkFilter.feed(payload.delta ?? '')
textBufferMode === 'strip' || if (visible) {
looksLikeLeakedReasoningPrefix(activeTextBuffer)
) {
textBufferMode = 'strip'
continue
}
if (textBufferMode === 'pending') {
if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) {
continue
}
yield { yield {
type: 'content_block_delta', type: 'content_block_delta',
index: activeTextBlockIndex, index: activeTextBlockIndex,
delta: { delta: {
type: 'text_delta', type: 'text_delta',
text: activeTextBuffer, text: visible,
}, },
} }
textBufferMode = 'none'
continue
}
if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) {
textBufferMode = 'pending'
continue
}
yield {
type: 'content_block_delta',
index: activeTextBlockIndex,
delta: {
type: 'text_delta',
text: payload.delta ?? '',
},
} }
} }
continue continue
@@ -923,7 +937,7 @@ export function convertCodexResponseToAnthropicMessage(
if (part?.type === 'output_text') { if (part?.type === 'output_text') {
content.push({ content.push({
type: 'text', type: 'text',
text: stripLeakedReasoningPreamble(part.text ?? ''), text: stripThinkTags(part.text ?? ''),
}) })
} }
} }

View File

@@ -1,7 +1,13 @@
import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode } from '../../utils/envUtils.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
isCodexBaseUrl, isCodexBaseUrl,
resolveCodexApiCredentials, resolveRuntimeCodexCredentials,
resolveProviderRequest, resolveProviderRequest,
} from './providerConfig.js' } from './providerConfig.js'
@@ -391,6 +397,18 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string {
} }
export async function fetchCodexUsage(): Promise<CodexUsageData> { export async function fetchCodexUsage(): Promise<CodexUsageData> {
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
async error => {
logForDebugging(
`[codex] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
return {
refreshed: false,
credentials: await readCodexCredentialsAsync(),
}
},
)
const request = resolveProviderRequest({ const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL, model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL, baseUrl: process.env.OPENAI_BASE_URL,
@@ -401,16 +419,19 @@ export async function fetchCodexUsage(): Promise<CodexUsageData> {
) )
} }
const credentials = resolveCodexApiCredentials() const credentials = resolveRuntimeCodexCredentials({
storedCredentials: refreshResult.credentials,
})
if (!credentials.apiKey) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}` ? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
: '' : oauthHint
throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`) throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`)
} }
if (!credentials.accountId) { if (!credentials.accountId) {
throw new Error( throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
) )
} }

View File

@@ -0,0 +1,44 @@
import { APIError } from '@anthropic-ai/sdk'
import { expect, test } from 'bun:test'
import { getAssistantMessageFromError } from './errors.js'
function getFirstText(message: ReturnType<typeof getAssistantMessageFromError>): string {
const first = message.message.content[0]
if (!first || typeof first !== 'object' || !('text' in first)) {
return ''
}
return typeof first.text === 'string' ? first.text : ''
}
test('maps endpoint_not_found category markers to actionable setup guidance', () => {
const error = APIError.generate(
404,
undefined,
'OpenAI API error 404: Not Found [openai_category=endpoint_not_found] Hint: Confirm OPENAI_BASE_URL includes /v1.',
new Headers(),
)
const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b')
const text = getFirstText(message)
expect(message.isApiErrorMessage).toBe(true)
expect(text).toContain('Provider endpoint was not found')
expect(text).toContain('OPENAI_BASE_URL')
expect(text).toContain('/v1')
})
test('maps tool_call_incompatible category markers to model/tool guidance', () => {
const error = APIError.generate(
400,
undefined,
'OpenAI API error 400: tool_calls are not supported [openai_category=tool_call_incompatible]',
new Headers(),
)
const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b')
const text = getFirstText(message)
expect(text).toContain('rejected tool-calling payloads')
expect(text).toContain('/model')
})

View File

@@ -50,9 +50,110 @@ import {
} from '../claudeAiLimits.js' } from '../claudeAiLimits.js'
import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js' import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
import {
extractOpenAICategoryMarker,
type OpenAICompatibilityFailureCategory,
} from './openaiErrorClassification.js'
export const API_ERROR_MESSAGE_PREFIX = 'API Error' export const API_ERROR_MESSAGE_PREFIX = 'API Error'
function stripOpenAICompatibilityMetadata(message: string): string {
return message
.replace(/\s*\[openai_category=[a-z_]+\]\s*/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
}
function mapOpenAICompatibilityFailureToAssistantMessage(options: {
category: OpenAICompatibilityFailureCategory
model: string
rawMessage: string
}): AssistantMessage {
const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model'
const compactHint = getIsNonInteractiveSession()
? 'Reduce prompt size or start a new session.'
: 'Run /compact or start a new session with /new.'
switch (options.category) {
case 'localhost_resolution_failed':
case 'connection_refused':
return createAssistantAPIErrorMessage({
content:
'Could not connect to the local OpenAI-compatible provider. Ensure the local server is running, then use OPENAI_BASE_URL=http://127.0.0.1:11434/v1 for Ollama.',
error: 'unknown',
})
case 'endpoint_not_found':
return createAssistantAPIErrorMessage({
content:
'Provider endpoint was not found. Confirm OPENAI_BASE_URL targets an OpenAI-compatible /v1 endpoint (for Ollama: http://127.0.0.1:11434/v1).',
error: 'invalid_request',
})
case 'model_not_found':
return createAssistantAPIErrorMessage({
content: `The selected model (${options.model}) is not available on this provider. Run ${switchCmd} to choose another model, or verify installed local models (for Ollama: ollama list).`,
error: 'invalid_request',
})
case 'auth_invalid':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEY and endpoint-specific auth requirements.`,
error: 'authentication_failed',
})
case 'rate_limited':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider rate limit reached. Retry in a few seconds.`,
error: 'rate_limit',
})
case 'request_timeout':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider request timed out. Local models may be loading or overloaded; retry shortly or increase API_TIMEOUT_MS.`,
error: 'unknown',
})
case 'context_overflow':
return createAssistantAPIErrorMessage({
content: `The conversation exceeded the provider context limit. ${compactHint}`,
error: 'invalid_request',
})
case 'tool_call_incompatible':
return createAssistantAPIErrorMessage({
content: `The selected provider/model rejected tool-calling payloads. Try ${switchCmd} to pick a tool-capable model or continue without tools.`,
error: 'invalid_request',
})
case 'malformed_provider_response':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider returned a malformed response. Confirm endpoint compatibility and check local proxy/network middleware.`,
error: 'unknown',
errorDetails: stripOpenAICompatibilityMetadata(options.rawMessage),
})
case 'provider_unavailable':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider is temporarily unavailable. Retry in a moment.`,
error: 'unknown',
})
case 'network_error':
case 'unknown':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
error: 'unknown',
})
default:
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
error: 'unknown',
})
}
}
export function startsWithApiErrorPrefix(text: string): boolean { export function startsWithApiErrorPrefix(text: string): boolean {
return ( return (
text.startsWith(API_ERROR_MESSAGE_PREFIX) || text.startsWith(API_ERROR_MESSAGE_PREFIX) ||
@@ -457,6 +558,19 @@ export function getAssistantMessageFromError(
}) })
} }
// OpenAI-compatible transport and HTTP failures include structured category
// markers from openaiShim.ts for actionable end-user remediation.
if (error instanceof APIError) {
const openaiCategory = extractOpenAICategoryMarker(error.message)
if (openaiCategory) {
return mapOpenAICompatibilityFailureToAssistantMessage({
category: openaiCategory,
model,
rawMessage: error.message,
})
}
}
// Check for emergency capacity off switch for Opus PAYG users // Check for emergency capacity off switch for Opus PAYG users
if ( if (
error instanceof Error && error instanceof Error &&
@@ -924,6 +1038,30 @@ export function getAssistantMessageFromError(
}) })
} }
// 500 errors caused by context overflow — the API returns 500 instead of 400
// when the request body (including conversation context) exceeds limits.
// This happens when auto-compact fails or the token estimation undercounts.
// Detect by checking for context-related keywords in 500 responses.
if (
error instanceof APIError &&
error.status >= 500 &&
(error.message.toLowerCase().includes('too many tokens') ||
error.message.toLowerCase().includes('request too large') ||
error.message.toLowerCase().includes('context length') ||
error.message.toLowerCase().includes('maximum context') ||
error.message.toLowerCase().includes('input length') ||
error.message.toLowerCase().includes('payload too large'))
) {
const rewindInstruction = getIsNonInteractiveSession()
? ''
: ' Press esc twice to go up a few messages, or run /compact to reduce context.'
return createAssistantAPIErrorMessage({
content: `The conversation has grown too large for the API to process.${rewindInstruction} Alternatively, start a new session with /new.`,
error: 'invalid_request',
errorDetails: `Context overflow (500): ${error.message}`,
})
}
// Connection errors (non-timeout) — use formatAPIError for detailed messages // Connection errors (non-timeout) — use formatAPIError for detailed messages
if (error instanceof APIConnectionError) { if (error instanceof APIConnectionError) {
return createAssistantAPIErrorMessage({ return createAssistantAPIErrorMessage({

View File

@@ -0,0 +1,86 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { _resetKeepAliveForTesting } from '../../utils/proxy.js'
import {
fetchWithProxyRetry,
isRetryableFetchError,
} from './fetchWithProxyRetry.js'
type FetchType = typeof globalThis.fetch
const originalFetch = globalThis.fetch
const originalEnv = {
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
}
function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
beforeEach(() => {
process.env.HTTP_PROXY = 'http://127.0.0.1:15236'
delete process.env.HTTPS_PROXY
_resetKeepAliveForTesting()
})
afterEach(() => {
globalThis.fetch = originalFetch
restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY)
restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY)
_resetKeepAliveForTesting()
})
test('isRetryableFetchError matches Bun socket-closed failures', () => {
expect(
isRetryableFetchError(
new Error(
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
),
),
).toBe(true)
})
test('fetchWithProxyRetry retries once with keepalive disabled after socket closure', async () => {
const calls: Array<RequestInit | undefined> = []
globalThis.fetch = (async (_input, init) => {
calls.push(init)
if (calls.length === 1) {
throw new Error(
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
)
}
return new Response('ok')
}) as FetchType
const response = await fetchWithProxyRetry('https://example.com/search', {
method: 'POST',
})
expect(await response.text()).toBe('ok')
expect(calls).toHaveLength(2)
expect((calls[0] as RequestInit & { proxy?: string }).proxy).toBe(
'http://127.0.0.1:15236',
)
expect((calls[0] as RequestInit).keepalive).toBeUndefined()
expect((calls[1] as RequestInit).keepalive).toBe(false)
})
test('fetchWithProxyRetry does not retry non-network errors', async () => {
let attempts = 0
globalThis.fetch = (async () => {
attempts += 1
throw new Error('400 bad request')
}) as FetchType
await expect(fetchWithProxyRetry('https://example.com')).rejects.toThrow(
'400 bad request',
)
expect(attempts).toBe(1)
})

View File

@@ -0,0 +1,44 @@
import { disableKeepAlive, getProxyFetchOptions } from '../../utils/proxy.js'
const RETRYABLE_FETCH_ERROR_PATTERN =
/socket connection was closed unexpectedly|ECONNRESET|EPIPE|socket hang up|Connection reset by peer|fetch failed/i
export function isRetryableFetchError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
if (error.name === 'AbortError') {
return false
}
return RETRYABLE_FETCH_ERROR_PATTERN.test(error.message)
}
export async function fetchWithProxyRetry(
input: string | URL | Request,
init?: RequestInit,
options?: { forAnthropicAPI?: boolean; maxAttempts?: number },
): Promise<Response> {
const maxAttempts = Math.max(1, options?.maxAttempts ?? 2)
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetch(input, {
...init,
...getProxyFetchOptions({
forAnthropicAPI: options?.forAnthropicAPI,
}),
})
} catch (error) {
lastError = error
if (attempt >= maxAttempts || !isRetryableFetchError(error)) {
throw error
}
disableKeepAlive()
}
}
throw lastError instanceof Error
? lastError
: new Error('Fetch failed without an error object')
}

View File

@@ -0,0 +1,97 @@
import { expect, test } from 'bun:test'
import {
buildOpenAICompatibilityErrorMessage,
classifyOpenAIHttpFailure,
classifyOpenAINetworkFailure,
extractOpenAICategoryMarker,
formatOpenAICategoryMarker,
} from './openaiErrorClassification.js'
test('classifies localhost ECONNREFUSED as connection_refused', () => {
const error = Object.assign(new TypeError('fetch failed'), {
code: 'ECONNREFUSED',
})
const failure = classifyOpenAINetworkFailure(error, {
url: 'http://localhost:11434/v1/chat/completions',
})
expect(failure.category).toBe('connection_refused')
expect(failure.retryable).toBe(true)
expect(failure.code).toBe('ECONNREFUSED')
expect(failure.hint).toContain('local server is running')
})
test('classifies localhost ENOTFOUND as localhost_resolution_failed', () => {
const error = Object.assign(new TypeError('getaddrinfo ENOTFOUND localhost'), {
code: 'ENOTFOUND',
})
const failure = classifyOpenAINetworkFailure(error, {
url: 'http://localhost:11434/v1/chat/completions',
})
expect(failure.category).toBe('localhost_resolution_failed')
expect(failure.retryable).toBe(true)
expect(failure.code).toBe('ENOTFOUND')
expect(failure.hint).toContain('127.0.0.1')
})
test('classifies model-not-found 404 responses', () => {
const failure = classifyOpenAIHttpFailure({
status: 404,
body: 'The model qwen2.5-coder:7b was not found',
})
expect(failure.category).toBe('model_not_found')
expect(failure.retryable).toBe(false)
})
test('classifies generic 404 responses as endpoint_not_found', () => {
const failure = classifyOpenAIHttpFailure({
status: 404,
body: 'Not Found',
})
expect(failure.category).toBe('endpoint_not_found')
expect(failure.hint).toContain('/v1')
})
test('classifies context-overflow responses', () => {
const failure = classifyOpenAIHttpFailure({
status: 500,
body: 'request too large: maximum context length exceeded',
})
expect(failure.category).toBe('context_overflow')
expect(failure.retryable).toBe(false)
})
test('classifies tool compatibility failures', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'tool_calls are not supported by this model',
})
expect(failure.category).toBe('tool_call_incompatible')
})
test('embeds and extracts category markers in formatted messages', () => {
const marker = formatOpenAICategoryMarker('endpoint_not_found')
expect(marker).toBe('[openai_category=endpoint_not_found]')
const formatted = buildOpenAICompatibilityErrorMessage('OpenAI API error 404: Not Found', {
category: 'endpoint_not_found',
hint: 'Confirm OPENAI_BASE_URL includes /v1.',
})
expect(formatted).toContain('[openai_category=endpoint_not_found]')
expect(formatted).toContain('Hint: Confirm OPENAI_BASE_URL includes /v1.')
expect(extractOpenAICategoryMarker(formatted)).toBe('endpoint_not_found')
})
test('ignores unknown category markers during extraction', () => {
const malformed = 'OpenAI API error 500 [openai_category=totally_fake_category]'
expect(extractOpenAICategoryMarker(malformed)).toBeUndefined()
})

View File

@@ -0,0 +1,352 @@
export type OpenAICompatibilityFailureCategory =
| 'connection_refused'
| 'localhost_resolution_failed'
| 'request_timeout'
| 'network_error'
| 'auth_invalid'
| 'rate_limited'
| 'model_not_found'
| 'endpoint_not_found'
| 'context_overflow'
| 'tool_call_incompatible'
| 'malformed_provider_response'
| 'provider_unavailable'
| 'unknown'
export type OpenAICompatibilityFailure = {
source: 'network' | 'http'
category: OpenAICompatibilityFailureCategory
retryable: boolean
message: string
hint?: string
code?: string
status?: number
}
const OPENAI_CATEGORY_MARKER_PREFIX = '[openai_category='
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1'])
const OPENAI_COMPATIBILITY_FAILURE_CATEGORIES: ReadonlySet<OpenAICompatibilityFailureCategory> =
new Set<OpenAICompatibilityFailureCategory>([
'connection_refused',
'localhost_resolution_failed',
'request_timeout',
'network_error',
'auth_invalid',
'rate_limited',
'model_not_found',
'endpoint_not_found',
'context_overflow',
'tool_call_incompatible',
'malformed_provider_response',
'provider_unavailable',
'unknown',
])
function isOpenAICompatibilityFailureCategory(
value: string,
): value is OpenAICompatibilityFailureCategory {
return OPENAI_COMPATIBILITY_FAILURE_CATEGORIES.has(
value as OpenAICompatibilityFailureCategory,
)
}
function getErrorCode(error: unknown): string | undefined {
let current: unknown = error
const maxDepth = 5
for (let depth = 0; depth < maxDepth; depth++) {
if (
current &&
typeof current === 'object' &&
'code' in current &&
typeof (current as { code?: unknown }).code === 'string'
) {
return (current as { code: string }).code
}
if (
current &&
typeof current === 'object' &&
'cause' in current &&
(current as { cause?: unknown }).cause !== current
) {
current = (current as { cause?: unknown }).cause
continue
}
break
}
return undefined
}
function getHostname(url: string): string | null {
try {
return new URL(url).hostname.toLowerCase()
} catch {
return null
}
}
function isLocalhostLikeHostname(hostname: string | null): boolean {
if (!hostname) return false
if (LOCALHOST_HOSTNAMES.has(hostname)) return true
return /^127\./.test(hostname)
}
function isContextOverflowMessage(body: string): boolean {
const lower = body.toLowerCase()
return (
lower.includes('too many tokens') ||
lower.includes('request too large') ||
lower.includes('context length') ||
lower.includes('maximum context') ||
lower.includes('input length') ||
lower.includes('payload too large') ||
lower.includes('prompt is too long')
)
}
function isToolCompatibilityMessage(body: string): boolean {
const lower = body.toLowerCase()
return (
lower.includes('tool_calls') ||
lower.includes('tool_call') ||
lower.includes('tool_use') ||
lower.includes('tool_result') ||
lower.includes('function calling') ||
lower.includes('function call')
)
}
function isMalformedProviderResponse(body: string): boolean {
const lower = body.toLowerCase()
return (
lower.includes('<!doctype html') ||
lower.includes('<html') ||
lower.includes('invalid json') ||
lower.includes('malformed') ||
lower.includes('unexpected token') ||
lower.includes('cannot parse') ||
lower.includes('not valid json')
)
}
function isModelNotFoundMessage(body: string): boolean {
const lower = body.toLowerCase()
return (
lower.includes('model') &&
(
lower.includes('not found') ||
lower.includes('does not exist') ||
lower.includes('unknown model') ||
lower.includes('unavailable model')
)
)
}
export function formatOpenAICategoryMarker(
category: OpenAICompatibilityFailureCategory,
): string {
return `${OPENAI_CATEGORY_MARKER_PREFIX}${category}]`
}
export function extractOpenAICategoryMarker(
message: string,
): OpenAICompatibilityFailureCategory | undefined {
const match = message.match(/\[openai_category=([a-z_]+)]/)
const category = match?.[1]
if (!category || !isOpenAICompatibilityFailureCategory(category)) {
return undefined
}
return category
}
export function buildOpenAICompatibilityErrorMessage(
baseMessage: string,
failure: Pick<OpenAICompatibilityFailure, 'category' | 'hint'>,
): string {
const marker = formatOpenAICategoryMarker(failure.category)
const hint = failure.hint ? ` Hint: ${failure.hint}` : ''
return `${baseMessage} ${marker}${hint}`
}
export function classifyOpenAINetworkFailure(
error: unknown,
options: { url: string },
): OpenAICompatibilityFailure {
const message = error instanceof Error ? error.message : String(error)
const lowerMessage = message.toLowerCase()
const code = getErrorCode(error)
const hostname = getHostname(options.url)
const isLocalHost = isLocalhostLikeHostname(hostname)
if (
code === 'ETIMEDOUT' ||
code === 'UND_ERR_CONNECT_TIMEOUT' ||
lowerMessage.includes('timeout') ||
lowerMessage.includes('timed out') ||
lowerMessage.includes('aborterror')
) {
return {
source: 'network',
category: 'request_timeout',
retryable: true,
message,
code,
hint: 'The provider took too long to respond. Check local model load time or increase API timeout.',
}
}
if (
isLocalHost &&
(
code === 'ENOTFOUND' ||
code === 'EAI_AGAIN' ||
lowerMessage.includes('getaddrinfo') ||
(code === undefined && lowerMessage.includes('fetch failed'))
)
) {
return {
source: 'network',
category: 'localhost_resolution_failed',
retryable: true,
message,
code,
hint: 'Localhost failed for this request. Retry with 127.0.0.1 and confirm Ollama is serving on the configured port.',
}
}
if (code === 'ECONNREFUSED') {
return {
source: 'network',
category: 'connection_refused',
retryable: true,
message,
code,
hint: isLocalHost
? 'Connection to the local provider was refused. Ensure the local server is running and listening on the configured port.'
: 'Connection was refused by the provider endpoint. Ensure the server is running and the port is correct.',
}
}
return {
source: 'network',
category: 'network_error',
retryable: true,
message,
code,
hint: 'Network transport failed before a provider response was received.',
}
}
export function classifyOpenAIHttpFailure(options: {
status: number
body: string
}): OpenAICompatibilityFailure {
const body = options.body ?? ''
if (options.status === 401 || options.status === 403) {
return {
source: 'http',
category: 'auth_invalid',
retryable: false,
status: options.status,
message: body,
hint: 'Authentication failed. Verify API key, token source, and endpoint-specific auth headers.',
}
}
if (options.status === 429) {
return {
source: 'http',
category: 'rate_limited',
retryable: true,
status: options.status,
message: body,
hint: 'Provider rate-limited the request. Retry after backoff.',
}
}
if (options.status === 404 && isModelNotFoundMessage(body)) {
return {
source: 'http',
category: 'model_not_found',
retryable: false,
status: options.status,
message: body,
hint: 'The selected model is not installed or not available on this endpoint.',
}
}
if (options.status === 404) {
return {
source: 'http',
category: 'endpoint_not_found',
retryable: false,
status: options.status,
message: body,
hint: 'Endpoint was not found. Confirm OPENAI_BASE_URL includes /v1 for OpenAI-compatible local providers.',
}
}
if (
options.status === 413 ||
((options.status === 400 || options.status >= 500) &&
isContextOverflowMessage(body))
) {
return {
source: 'http',
category: 'context_overflow',
retryable: false,
status: options.status,
message: body,
hint: 'Prompt context exceeded model/server limits. Reduce context or increase provider context length.',
}
}
if (options.status === 400 && isToolCompatibilityMessage(body)) {
return {
source: 'http',
category: 'tool_call_incompatible',
retryable: false,
status: options.status,
message: body,
hint: 'Provider/model rejected tool-calling payload. Retry without tools or use a tool-capable model.',
}
}
if (options.status >= 400 && isMalformedProviderResponse(body)) {
return {
source: 'http',
category: 'malformed_provider_response',
retryable: false,
status: options.status,
message: body,
hint: 'Provider returned malformed or non-JSON response where JSON was expected.',
}
}
if (options.status >= 500) {
return {
source: 'http',
category: 'provider_unavailable',
retryable: true,
status: options.status,
message: body,
hint: 'Provider reported a server-side failure. Retry after a short delay.',
}
}
return {
source: 'http',
category: 'unknown',
retryable: false,
status: options.status,
message: body,
}
}

View File

@@ -0,0 +1,286 @@
import { afterEach, expect, mock, test } from 'bun:test'
const originalFetch = globalThis.fetch
const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
globalThis.fetch = originalFetch
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
mock.restore()
})
test('logs classified transport diagnostics with category and code', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
process.env.OPENAI_API_KEY = 'ollama'
const transportError = Object.assign(new TypeError('fetch failed'), {
code: 'ECONNREFUSED',
})
globalThis.fetch = mock(async () => {
throw transportError
}) as typeof globalThis.fetch
const client = createOpenAIShimClient({}) as {
beta: {
messages: {
create: (params: Record<string, unknown>) => Promise<unknown>
}
}
}
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).rejects.toThrow('openai_category=connection_refused')
const transportLog = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' && call[0].includes('transport failure'),
)
expect(transportLog).toBeDefined()
expect(String(transportLog?.[0])).toContain('category=connection_refused')
expect(String(transportLog?.[0])).toContain('code=ECONNREFUSED')
expect(transportLog?.[1]).toEqual({ level: 'warn' })
})
test('redacts credentials in transport diagnostic URL logs', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
process.env.OPENAI_BASE_URL = 'http://user:supersecret@localhost:11434/v1'
process.env.OPENAI_API_KEY = 'supersecret'
const transportError = Object.assign(new TypeError('fetch failed'), {
code: 'ECONNREFUSED',
})
globalThis.fetch = mock(async () => {
throw transportError
}) as typeof globalThis.fetch
const client = createOpenAIShimClient({}) as {
beta: {
messages: {
create: (params: Record<string, unknown>) => Promise<unknown>
}
}
}
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).rejects.toThrow('openai_category=connection_refused')
const transportLog = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' && call[0].includes('transport failure'),
)
expect(transportLog).toBeDefined()
const logLine = String(transportLog?.[0])
expect(logLine).toContain('url=http://redacted:redacted@localhost:11434/v1/chat/completions')
expect(logLine).not.toContain('user:supersecret')
expect(logLine).not.toContain('supersecret@')
})
test('logs self-heal localhost fallback with redacted from/to URLs', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
process.env.OPENAI_BASE_URL = 'http://user:supersecret@localhost:11434/v1'
process.env.OPENAI_API_KEY = 'supersecret'
globalThis.fetch = mock(async (input: string | Request) => {
const url = typeof input === 'string' ? input : input.url
if (url.includes('localhost')) {
throw Object.assign(new TypeError('fetch failed'), {
code: 'ENOTFOUND',
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as typeof globalThis.fetch
const client = createOpenAIShimClient({}) as {
beta: {
messages: {
create: (params: Record<string, unknown>) => Promise<unknown>
}
}
}
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
const fallbackLog = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' &&
call[0].includes('self-heal retry reason=localhost_resolution_failed'),
)
expect(fallbackLog).toBeDefined()
const logLine = String(fallbackLog?.[0])
expect(logLine).toContain('from=http://redacted:redacted@localhost:11434/v1/chat/completions')
expect(logLine).toContain('to=http://redacted:redacted@127.0.0.1:11434/v1/chat/completions')
expect(logLine).not.toContain('supersecret')
})
test('logs self-heal toolless retry for local tool-call incompatibility', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
process.env.OPENAI_API_KEY = 'ollama'
let callCount = 0
globalThis.fetch = mock(async () => {
callCount += 1
if (callCount === 1) {
return new Response('tool_calls are not supported', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 7,
completion_tokens: 3,
total_tokens: 10,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as typeof globalThis.fetch
const client = createOpenAIShimClient({}) as {
beta: {
messages: {
create: (params: Record<string, unknown>) => Promise<unknown>
}
}
}
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
name: 'Read',
description: 'Read file',
input_schema: {
type: 'object',
properties: {
filePath: { type: 'string' },
},
required: ['filePath'],
},
},
],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
const fallbackLog = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' &&
call[0].includes('self-heal retry reason=tool_call_incompatible mode=toolless'),
)
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.[1]).toEqual({ level: 'warn' })
})

View File

@@ -403,6 +403,97 @@ test('preserves usage from final OpenAI stream chunk with empty choices', async
expect(usageEvent?.usage?.output_tokens).toBe(45) expect(usageEvent?.usage?.output_tokens).toBe(45)
}) })
test('uses max_tokens instead of max_completion_tokens for local providers', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body))
expect(body.max_tokens).toBe(64)
expect(body.max_completion_tokens).toBeUndefined()
expect(body.stream_options).toBeUndefined()
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'llama3.1:8b',
choices: [
{
message: {
role: 'assistant',
content: 'hello',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 1,
total_tokens: 6,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'llama3.1:8b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
})
test('keeps max_completion_tokens for non-local non-github providers', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body))
expect(body.max_completion_tokens).toBe(64)
expect(body.max_tokens).toBeUndefined()
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-4o',
choices: [
{
message: {
role: 'assistant',
content: 'hello',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 1,
total_tokens: 6,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
})
test('preserves Gemini tool call extra_content in follow-up requests', async () => { test('preserves Gemini tool call extra_content in follow-up requests', async () => {
let requestBody: Record<string, unknown> | undefined let requestBody: Record<string, unknown> | undefined
@@ -689,9 +780,117 @@ test('preserves image tool results as placeholders in follow-up requests', async
const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find( const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find(
message => message.role === 'tool', message => message.role === 'tool',
) as { content?: string } | undefined ) as {
content?: Array<{
type: string
text?: string
image_url?: { url: string }
}> | string
} | undefined
expect(toolMessage?.content).toContain('[image:image/png]') expect(Array.isArray(toolMessage?.content)).toBe(true)
const parts = toolMessage?.content as Array<{
type: string
text?: string
image_url?: { url: string }
}>
const imagePart = parts.find(part => part.type === 'image_url')
expect(imagePart?.image_url?.url).toBe('data:image/png;base64,ZmFrZQ==')
})
test('preserves mixed text and image tool results as multipart content', async () => {
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-4o',
choices: [
{
message: {
role: 'assistant',
content: 'done',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-4o',
system: 'test system',
messages: [
{ role: 'user', content: 'Read this screenshot' },
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call_image_2',
name: 'Read',
input: { file_path: 'C:\\temp\\screenshot.png' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call_image_2',
content: [
{ type: 'text', text: 'Screenshot captured' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'ZmFrZQ==',
},
},
],
},
],
},
],
max_tokens: 64,
stream: false,
})
const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find(
message => message.role === 'tool',
) as {
content?: Array<{
type: string
text?: string
image_url?: { url: string }
}>
} | undefined
expect(Array.isArray(toolMessage?.content)).toBe(true)
const parts = toolMessage?.content ?? []
expect(parts[0]).toEqual({ type: 'text', text: 'Screenshot captured' })
expect(parts[1]).toEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,ZmFrZQ==' },
})
}) })
test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => { test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => {
@@ -2314,7 +2513,7 @@ test('non-streaming: real content takes precedence over reasoning_content', asyn
]) ])
}) })
test('non-streaming: strips leaked reasoning preamble from assistant content', async () => { test('non-streaming: strips <think> tag block from assistant content', async () => {
globalThis.fetch = (async () => { globalThis.fetch = (async () => {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -2325,7 +2524,7 @@ test('non-streaming: strips leaked reasoning preamble from assistant content', a
message: { message: {
role: 'assistant', role: 'assistant',
content: content:
'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?', '<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?',
}, },
finish_reason: 'stop', finish_reason: 'stop',
}, },
@@ -2446,7 +2645,7 @@ test('streaming: thinking block closed before tool call', async () => {
expect(thinkingStart?.content_block?.type).toBe('thinking') expect(thinkingStart?.content_block?.type).toBe('thinking')
}) })
test('streaming: strips leaked reasoning preamble from assistant content deltas', async () => { test('streaming: strips <think> tag block from assistant content deltas', async () => {
globalThis.fetch = (async () => { globalThis.fetch = (async () => {
const chunks = makeStreamChunks([ const chunks = makeStreamChunks([
{ {
@@ -2459,7 +2658,7 @@ test('streaming: strips leaked reasoning preamble from assistant content deltas'
delta: { delta: {
role: 'assistant', role: 'assistant',
content: content:
'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?', '<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?',
}, },
finish_reason: null, finish_reason: null,
}, },
@@ -2501,10 +2700,10 @@ test('streaming: strips leaked reasoning preamble from assistant content deltas'
} }
} }
expect(textDeltas).toEqual(['Hey! How can I help you today?']) expect(textDeltas.join('')).toBe('Hey! How can I help you today?')
}) })
test('streaming: strips leaked reasoning preamble when split across multiple content chunks', async () => { test('streaming: strips <think> tag split across multiple content chunks', async () => {
globalThis.fetch = (async () => { globalThis.fetch = (async () => {
const chunks = makeStreamChunks([ const chunks = makeStreamChunks([
{ {
@@ -2516,7 +2715,7 @@ test('streaming: strips leaked reasoning preamble when split across multiple con
index: 0, index: 0,
delta: { delta: {
role: 'assistant', role: 'assistant',
content: 'The user said "hey" - this is a simple greeting. ', content: '<think>user wants a greeting,',
}, },
finish_reason: null, finish_reason: null,
}, },
@@ -2530,8 +2729,21 @@ test('streaming: strips leaked reasoning preamble when split across multiple con
{ {
index: 0, index: 0,
delta: { delta: {
content: content: ' respond briefly</th',
'I should respond in a friendly, concise way.\n\nHey! How can I help you today?', },
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'gpt-5-mini',
choices: [
{
index: 0,
delta: {
content: 'ink>Hey! How can I help you today?',
}, },
finish_reason: null, finish_reason: null,
}, },
@@ -2574,5 +2786,434 @@ test('streaming: strips leaked reasoning preamble when split across multiple con
} }
} }
expect(textDeltas).toEqual(['Hey! How can I help you today?']) expect(textDeltas.join('')).toBe('Hey! How can I help you today?')
})
test('streaming: preserves prose without tags (no phrase-based false positive)', async () => {
// Regression: older phrase-based sanitizer would strip "I should..." prose.
// The tag-based approach leaves legitimate assistant output alone.
globalThis.fetch = (async () => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'gpt-5-mini',
choices: [
{
index: 0,
delta: {
role: 'assistant',
content:
'I should note that the user role requires a briefly concise friendly response format.',
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'gpt-5-mini',
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'gpt-5-mini',
system: 'test system',
messages: [{ role: 'user', content: 'hey' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const textDeltas: string[] = []
for await (const event of result.data) {
const delta = (event as { delta?: { type?: string; text?: string } }).delta
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
textDeltas.push(delta.text)
}
}
expect(textDeltas.join('')).toBe(
'I should note that the user role requires a briefly concise friendly response format.',
)
})
test('classifies localhost transport failures with actionable category marker', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
const transportError = Object.assign(new TypeError('fetch failed'), {
code: 'ECONNREFUSED',
})
globalThis.fetch = (async () => {
throw transportError
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).rejects.toThrow('openai_category=connection_refused')
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).rejects.toThrow('local server is running')
})
test('propagates AbortError without wrapping it as transport failure', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
const abortError = new DOMException('The operation was aborted.', 'AbortError')
globalThis.fetch = (async () => {
throw abortError
}) as FetchType
const controller = new AbortController()
controller.abort()
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create(
{
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
},
{ signal: controller.signal },
),
).rejects.toBe(abortError)
})
test('classifies chat-completions endpoint 404 failures with endpoint_not_found marker', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434'
globalThis.fetch = (async () =>
new Response('Not Found', {
status: 404,
headers: {
'Content-Type': 'text/plain',
},
})) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).rejects.toThrow('openai_category=endpoint_not_found')
})
test('self-heals localhost resolution failures by retrying local loopback base URL', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
const requestUrls: string[] = []
globalThis.fetch = (async (input, _init) => {
const url = typeof input === 'string' ? input : input.url
requestUrls.push(url)
if (url.includes('localhost')) {
const error = Object.assign(new TypeError('fetch failed'), {
code: 'ENOTFOUND',
})
throw error
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'hello from loopback',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
expect(requestUrls[0]).toBe('http://localhost:11434/v1/chat/completions')
expect(requestUrls).toContain('http://127.0.0.1:11434/v1/chat/completions')
})
test('self-heals local endpoint_not_found by retrying with /v1 base URL', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434'
const requestUrls: string[] = []
globalThis.fetch = (async (input, _init) => {
const url = typeof input === 'string' ? input : input.url
requestUrls.push(url)
if (url === 'http://localhost:11434/chat/completions') {
return new Response('Not Found', {
status: 404,
headers: {
'Content-Type': 'text/plain',
},
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'hello from /v1',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
expect(requestUrls).toEqual([
'http://localhost:11434/chat/completions',
'http://localhost:11434/v1/chat/completions',
])
})
test('self-heals tool-call incompatibility by retrying local Ollama requests without tools', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
const requestBodies: Array<Record<string, unknown>> = []
globalThis.fetch = (async (_input, init) => {
const requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>
requestBodies.push(requestBody)
if (requestBodies.length === 1) {
return new Response('tool_calls are not supported', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'fallback without tools',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 8,
completion_tokens: 4,
total_tokens: 12,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
name: 'Read',
description: 'Read a file',
input_schema: {
type: 'object',
properties: {
filePath: { type: 'string' },
},
required: ['filePath'],
},
},
],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
expect(requestBodies).toHaveLength(2)
expect(Array.isArray(requestBodies[0]?.tools)).toBe(true)
expect(requestBodies[0]?.tool_choice).toBeUndefined()
expect(
requestBodies[1]?.tools === undefined ||
(Array.isArray(requestBodies[1]?.tools) && requestBodies[1]?.tools.length === 0),
).toBe(true)
expect(requestBodies[1]?.tool_choice).toBeUndefined()
})
test('preserves valid tool_result and drops orphan tool_result', async () => {
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'mistral-large-latest',
choices: [
{
message: {
role: 'assistant',
content: 'done',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'mistral-large-latest',
system: 'test system',
messages: [
{ role: 'user', content: 'Search and then I will interrupt' },
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'valid_call_1',
name: 'Search',
input: { query: 'openclaude' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'valid_call_1',
content: 'Found it!',
},
{
type: 'tool_result',
tool_use_id: 'orphan_call_2',
content: 'Interrupted result',
},
{
role: 'user',
content: 'What happened?',
},
],
},
],
max_tokens: 64,
stream: false,
})
const messages = requestBody?.messages as Array<Record<string, unknown>>
// Should have: system, user, assistant (tool_use), tool (valid_call_1), user
// Should NOT have: tool (orphan_call_2)
const toolMessages = messages.filter(m => m.role === 'tool')
expect(toolMessages.length).toBe(1)
expect(toolMessages[0].tool_call_id).toBe('valid_call_1')
const orphanMessage = toolMessages.find(m => m.tool_call_id === 'orphan_call_2')
expect(orphanMessage).toBeUndefined()
}) })

View File

@@ -22,15 +22,19 @@
*/ */
import { APIError } from '@anthropic-ai/sdk' import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js' import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js'
import { resolveGeminiCredential } from '../../utils/geminiAuth.js' import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js' import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import { import {
looksLikeLeakedReasoningPrefix, createThinkTagFilter,
shouldBufferPotentialReasoningPrefix, stripThinkTags,
stripLeakedReasoningPreamble, } from './thinkTagSanitizer.js'
} from './reasoningLeakSanitizer.js'
import { import {
codexStreamToAnthropic, codexStreamToAnthropic,
collectCodexCompletedResponse, collectCodexCompletedResponse,
@@ -42,12 +46,20 @@ import {
type AnthropicUsage, type AnthropicUsage,
type ShimCreateParams, type ShimCreateParams,
} from './codexShim.js' } from './codexShim.js'
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
import { import {
isLocalProviderUrl, getLocalProviderRetryBaseUrls,
resolveCodexApiCredentials,
resolveProviderRequest,
getGithubEndpointType, getGithubEndpointType,
isLocalProviderUrl,
resolveRuntimeCodexCredentials,
resolveProviderRequest,
shouldAttemptLocalToollessRetry,
} from './providerConfig.js' } from './providerConfig.js'
import {
buildOpenAICompatibilityErrorMessage,
classifyOpenAIHttpFailure,
classifyOpenAINetworkFailure,
} from './openaiErrorClassification.js'
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js' import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js' import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
import { import {
@@ -77,6 +89,19 @@ const COPILOT_HEADERS: Record<string, string> = {
'Copilot-Integration-Id': 'vscode-chat', 'Copilot-Integration-Id': 'vscode-chat',
} }
const SENSITIVE_URL_QUERY_PARAM_NAMES = [
'api_key',
'key',
'token',
'access_token',
'refresh_token',
'signature',
'sig',
'secret',
'password',
'authorization',
]
function isGithubModelsMode(): boolean { function isGithubModelsMode(): boolean {
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
} }
@@ -126,6 +151,34 @@ function formatRetryAfterHint(response: Response): string {
return ra ? ` (Retry-After: ${ra})` : '' return ra ? ` (Retry-After: ${ra})` : ''
} }
function shouldRedactUrlQueryParam(name: string): boolean {
const lower = name.toLowerCase()
return SENSITIVE_URL_QUERY_PARAM_NAMES.some(token => lower.includes(token))
}
function redactUrlForDiagnostics(url: string): string {
try {
const parsed = new URL(url)
if (parsed.username) {
parsed.username = 'redacted'
}
if (parsed.password) {
parsed.password = 'redacted'
}
for (const key of parsed.searchParams.keys()) {
if (shouldRedactUrlQueryParam(key)) {
parsed.searchParams.set(key, 'redacted')
}
}
const serialized = parsed.toString()
return redactSecretValueForDisplay(serialized, process.env as SecretValueSource) ?? serialized
} catch {
return redactSecretValueForDisplay(url, process.env as SecretValueSource) ?? url
}
}
function sleepMs(ms: number): Promise<void> { function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise(resolve => setTimeout(resolve, ms))
} }
@@ -176,35 +229,61 @@ function convertSystemPrompt(
return String(system) return String(system)
} }
function convertToolResultContent(content: unknown): string { function convertToolResultContent(
if (typeof content === 'string') return content content: unknown,
if (!Array.isArray(content)) return JSON.stringify(content ?? '') isError?: boolean,
): string | Array<{ type: string; text?: string; image_url?: { url: string } }> {
if (typeof content === 'string') {
return isError ? `Error: ${content}` : content
}
if (!Array.isArray(content)) {
const text = JSON.stringify(content ?? '')
return isError ? `Error: ${text}` : text
}
const chunks: string[] = [] const parts: Array<{
type: string
text?: string
image_url?: { url: string }
}> = []
for (const block of content) { for (const block of content) {
if (block?.type === 'text' && typeof block.text === 'string') { if (block?.type === 'text' && typeof block.text === 'string') {
chunks.push(block.text) parts.push({ type: 'text', text: block.text })
continue continue
} }
if (block?.type === 'image') { if (block?.type === 'image') {
const source = block.source const source = block.source
if (source?.type === 'url' && source.url) { if (source?.type === 'url' && source.url) {
chunks.push(`[Image](${source.url})`) parts.push({ type: 'image_url', image_url: { url: source.url } })
} else if (source?.type === 'base64') { } else if (source?.type === 'base64' && source.media_type && source.data) {
chunks.push(`[image:${source.media_type ?? 'unknown'}]`) parts.push({
} else { type: 'image_url',
chunks.push('[image]') image_url: {
url: `data:${source.media_type};base64,${source.data}`,
},
})
} }
continue continue
} }
if (typeof block?.text === 'string') { if (typeof block?.text === 'string') {
chunks.push(block.text) parts.push({ type: 'text', text: block.text })
} }
} }
return chunks.join('\n') if (parts.length === 0) return ''
if (parts.length === 1 && parts[0].type === 'text') {
const text = parts[0].text ?? ''
return isError ? `Error: ${text}` : text
}
if (isError && parts[0]?.type === 'text') {
parts[0] = { ...parts[0], text: `Error: ${parts[0].text ?? ''}` }
} else if (isError) {
parts.unshift({ type: 'text', text: 'Error:' })
}
return parts
} }
function convertContentBlocks( function convertContentBlocks(
@@ -271,6 +350,7 @@ function convertMessages(
system: unknown, system: unknown,
): OpenAIMessage[] { ): OpenAIMessage[] {
const result: OpenAIMessage[] = [] const result: OpenAIMessage[] = []
const knownToolCallIds = new Set<string>()
// System message first // System message first
const sysText = convertSystemPrompt(system) const sysText = convertSystemPrompt(system)
@@ -290,14 +370,21 @@ function convertMessages(
const toolResults = content.filter((b: { type?: string }) => b.type === 'tool_result') const toolResults = content.filter((b: { type?: string }) => b.type === 'tool_result')
const otherContent = content.filter((b: { type?: string }) => b.type !== 'tool_result') const otherContent = content.filter((b: { type?: string }) => b.type !== 'tool_result')
// Emit tool results as tool messages // Emit tool results as tool messages, but ONLY if we have a matching tool_use ID.
// Mistral/OpenAI strictly require tool messages to follow an assistant message with tool_calls.
// If the user interrupted (ESC) and a synthetic tool_result was generated without a recorded tool_use,
// emitting it here would cause a "role must alternate" or "unexpected role" error.
for (const tr of toolResults) { for (const tr of toolResults) {
const trContent = convertToolResultContent(tr.content) const id = tr.tool_use_id ?? 'unknown'
if (knownToolCallIds.has(id)) {
result.push({ result.push({
role: 'tool', role: 'tool',
tool_call_id: tr.tool_use_id ?? 'unknown', tool_call_id: id,
content: tr.is_error ? `Error: ${trContent}` : trContent, content: convertToolResultContent(tr.content, tr.is_error),
}) })
} else {
logForDebugging(`Dropping orphan tool_result for ID: ${id} to prevent API error`)
}
} }
// Emit remaining user content // Emit remaining user content
@@ -338,9 +425,11 @@ function convertMessages(
input?: unknown input?: unknown
extra_content?: Record<string, unknown> extra_content?: Record<string, unknown>
signature?: string signature?: string
}, index) => { }) => {
const id = tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`
knownToolCallIds.add(id)
const toolCall: NonNullable<OpenAIMessage['tool_calls']>[number] = { const toolCall: NonNullable<OpenAIMessage['tool_calls']>[number] = {
id: tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`, id,
type: 'function' as const, type: 'function' as const,
function: { function: {
name: tu.name ?? 'unknown', name: tu.name ?? 'unknown',
@@ -365,7 +454,6 @@ function convertMessages(
// Merge into existing google-specific metadata if present // Merge into existing google-specific metadata if present
const existingGoogle = (toolCall.extra_content?.google as Record<string, unknown>) ?? {} const existingGoogle = (toolCall.extra_content?.google as Record<string, unknown>) ?? {}
toolCall.extra_content = { toolCall.extra_content = {
...toolCall.extra_content, ...toolCall.extra_content,
google: { google: {
@@ -520,7 +608,10 @@ function convertTools(
function: { function: {
name: t.name, name: t.name,
description: t.description ?? '', description: t.description ?? '',
parameters: normalizeSchemaForOpenAI(schema, !isGemini), parameters: normalizeSchemaForOpenAI(
schema,
!isGemini && !isEnvTruthy(process.env.OPENCLAUDE_DISABLE_STRICT_TOOLS),
),
}, },
} }
}) })
@@ -611,6 +702,7 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null {
async function* openaiStreamToAnthropic( async function* openaiStreamToAnthropic(
response: Response, response: Response,
model: string, model: string,
signal?: AbortSignal,
): AsyncGenerator<AnthropicStreamEvent> { ): AsyncGenerator<AnthropicStreamEvent> {
const messageId = makeMessageId() const messageId = makeMessageId()
let contentBlockIndex = 0 let contentBlockIndex = 0
@@ -627,8 +719,7 @@ async function* openaiStreamToAnthropic(
let hasEmittedContentStart = false let hasEmittedContentStart = false
let hasEmittedThinkingStart = false let hasEmittedThinkingStart = false
let hasClosedThinking = false let hasClosedThinking = false
let activeTextBuffer = '' const thinkFilter = createThinkTagFilter()
let textBufferMode: 'none' | 'pending' | 'strip' = 'none'
let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null
let hasEmittedFinalUsage = false let hasEmittedFinalUsage = false
let hasProcessedFinishReason = false let hasProcessedFinishReason = false
@@ -658,18 +749,61 @@ async function* openaiStreamToAnthropic(
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
const STREAM_IDLE_TIMEOUT_MS = 120_000 // 2 minutes without data = connection likely dead
let lastDataTime = Date.now()
/**
* Read from the stream with an idle timeout. If no data arrives within
* STREAM_IDLE_TIMEOUT_MS, assume the connection is dead and throw so
* withRetry can reconnect. This prevents indefinite hangs on stale
* SSE connections from OpenAI/Gemini during long-running sessions.
* Respects the caller's AbortSignal — clears the idle timer on abort
* so the rejection reason is AbortError, not a spurious idle timeout.
*/
async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array>> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
const elapsed = Math.round((Date.now() - lastDataTime) / 1000)
reject(new Error(
`OpenAI/Gemini SSE stream idle for ${elapsed}s (limit: ${STREAM_IDLE_TIMEOUT_MS / 1000}s). Connection likely dropped.`,
))
}, STREAM_IDLE_TIMEOUT_MS)
// If the caller aborts, clear the timer so the AbortError surfaces
// cleanly instead of being masked by a spurious idle timeout.
let abortCleanup: (() => void) | undefined
if (signal) {
abortCleanup = () => {
clearTimeout(timeoutId)
}
signal.addEventListener('abort', abortCleanup, { once: true })
}
reader.read().then(
result => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
if (result.value) lastDataTime = Date.now()
resolve(result)
},
err => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
reject(err)
},
)
})
}
const closeActiveContentBlock = async function* () { const closeActiveContentBlock = async function* () {
if (!hasEmittedContentStart) return if (!hasEmittedContentStart) return
if (textBufferMode !== 'none') { const tail = thinkFilter.flush()
const sanitized = stripLeakedReasoningPreamble(activeTextBuffer) if (tail) {
if (sanitized) {
yield { yield {
type: 'content_block_delta', type: 'content_block_delta',
index: contentBlockIndex, index: contentBlockIndex,
delta: { type: 'text_delta', text: sanitized }, delta: { type: 'text_delta', text: tail },
}
} }
} }
@@ -679,13 +813,11 @@ async function* openaiStreamToAnthropic(
} }
contentBlockIndex++ contentBlockIndex++
hasEmittedContentStart = false hasEmittedContentStart = false
activeTextBuffer = ''
textBufferMode = 'none'
} }
try { try {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await readWithTimeout()
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
@@ -737,7 +869,6 @@ async function* openaiStreamToAnthropic(
contentBlockIndex++ contentBlockIndex++
hasClosedThinking = true hasClosedThinking = true
} }
activeTextBuffer += delta.content
if (!hasEmittedContentStart) { if (!hasEmittedContentStart) {
yield { yield {
type: 'content_block_start', type: 'content_block_start',
@@ -747,38 +878,13 @@ async function* openaiStreamToAnthropic(
hasEmittedContentStart = true hasEmittedContentStart = true
} }
if ( const visible = thinkFilter.feed(delta.content)
textBufferMode === 'strip' || if (visible) {
looksLikeLeakedReasoningPrefix(activeTextBuffer)
) {
textBufferMode = 'strip'
continue
}
if (textBufferMode === 'pending') {
if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) {
continue
}
yield { yield {
type: 'content_block_delta', type: 'content_block_delta',
index: contentBlockIndex, index: contentBlockIndex,
delta: { delta: { type: 'text_delta', text: visible },
type: 'text_delta',
text: activeTextBuffer,
},
} }
textBufferMode = 'none'
continue
}
if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) {
textBufferMode = 'pending'
continue
}
yield {
type: 'content_block_delta',
index: contentBlockIndex,
delta: { type: 'text_delta', text: delta.content },
} }
} }
@@ -1045,13 +1151,13 @@ class OpenAIShimMessages {
const isResponsesStream = response.url?.includes('/responses') const isResponsesStream = response.url?.includes('/responses')
return new OpenAIShimStream( return new OpenAIShimStream(
(request.transport === 'codex_responses' || isResponsesStream) (request.transport === 'codex_responses' || isResponsesStream)
? codexStreamToAnthropic(response, request.resolvedModel) ? codexStreamToAnthropic(response, request.resolvedModel, options?.signal)
: openaiStreamToAnthropic(response, request.resolvedModel), : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
) )
} }
if (request.transport === 'codex_responses') { if (request.transport === 'codex_responses') {
const data = await collectCodexCompletedResponse(response) const data = await collectCodexCompletedResponse(response, options?.signal)
return convertCodexResponseToAnthropicMessage( return convertCodexResponseToAnthropicMessage(
data, data,
request.resolvedModel, request.resolvedModel,
@@ -1114,7 +1220,6 @@ class OpenAIShimMessages {
const githubEndpointType = getGithubEndpointType(request.baseUrl) const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubMode = isGithubModelsMode() const isGithubMode = isGithubModelsMode()
const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses' const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses'
const isGithubCopilotEndpoint = isGithubMode && githubEndpointType === 'copilot'
if (isGithubWithCodexTransport) { if (isGithubWithCodexTransport) {
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
@@ -1141,11 +1246,26 @@ class OpenAIShimMessages {
} }
if (request.transport === 'codex_responses' && !isGithubMode) { if (request.transport === 'codex_responses' && !isGithubMode) {
const credentials = resolveCodexApiCredentials() const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
async error => {
logForDebugging(
`[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
return {
refreshed: false,
credentials: await readCodexCredentialsAsync(),
}
},
)
const credentials = resolveRuntimeCodexCredentials({
storedCredentials: refreshResult.credentials,
})
if (!credentials.apiKey) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}` ? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
: '' : oauthHint
const safeModel = const safeModel =
redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ?? redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ??
'the requested model' 'the requested model'
@@ -1155,7 +1275,7 @@ class OpenAIShimMessages {
} }
if (!credentials.accountId) { if (!credentials.accountId) {
throw new Error( throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
) )
} }
@@ -1216,18 +1336,20 @@ class OpenAIShimMessages {
const isGithub = isGithubModelsMode() const isGithub = isGithubModelsMode()
const isMistral = isMistralMode() const isMistral = isMistralMode()
const isLocal = isLocalProviderUrl(request.baseUrl)
const githubEndpointType = getGithubEndpointType(request.baseUrl) const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubCopilot = isGithub && githubEndpointType === 'copilot' const isGithubCopilot = isGithub && githubEndpointType === 'copilot'
const isGithubModels = isGithub && (githubEndpointType === 'models' || githubEndpointType === 'custom') const isGithubModels = isGithub && (githubEndpointType === 'models' || githubEndpointType === 'custom')
if ((isGithub || isMistral) && body.max_completion_tokens !== undefined) { if ((isGithub || isMistral || isLocal) && body.max_completion_tokens !== undefined) {
body.max_tokens = body.max_completion_tokens body.max_tokens = body.max_completion_tokens
delete body.max_completion_tokens delete body.max_completion_tokens
} }
// mistral also doesn't recognize body.store // mistral and gemini don't recognize body.store — Gemini returns 400
if (isMistral) { // "Invalid JSON payload received. Unknown name 'store': Cannot find field."
if (isMistral || isGeminiMode()) {
delete body.store delete body.store
} }
@@ -1268,8 +1390,12 @@ class OpenAIShimMessages {
...filterAnthropicHeaders(options?.headers), ...filterAnthropicHeaders(options?.headers),
} }
const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) const isGemini = isGeminiMode()
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' const isMiniMax = !!process.env.MINIMAX_API_KEY
const apiKey =
this.providerOverride?.apiKey ??
process.env.OPENAI_API_KEY ??
(isMiniMax ? process.env.MINIMAX_API_KEY : '')
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via // Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
// path segments like https://evil.com/cognitiveservices.azure.com/ // path segments like https://evil.com/cognitiveservices.azure.com/
let isAzure = false let isAzure = false
@@ -1303,42 +1429,192 @@ class OpenAIShimMessages {
headers['X-GitHub-Api-Version'] = '2022-11-28' headers['X-GitHub-Api-Version'] = '2022-11-28'
} }
// Build the chat completions URL const buildChatCompletionsUrl = (baseUrl: string): string => {
// Azure Cognitive Services / Azure OpenAI require a deployment-specific path // Azure Cognitive Services / Azure OpenAI require a deployment-specific
// and an api-version query parameter. // path and an api-version query parameter.
// Standard format: {base}/openai/deployments/{model}/chat/completions?api-version={version}
// Non-Azure: {base}/chat/completions
let chatCompletionsUrl: string
if (isAzure) { if (isAzure) {
const apiVersion = process.env.AZURE_OPENAI_API_VERSION ?? '2024-12-01-preview' const apiVersion = process.env.AZURE_OPENAI_API_VERSION ?? '2024-12-01-preview'
const deployment = request.resolvedModel ?? process.env.OPENAI_MODEL ?? 'gpt-4o' const deployment = request.resolvedModel ?? process.env.OPENAI_MODEL ?? 'gpt-4o'
// If base URL already contains /deployments/, use it as-is with api-version
if (/\/deployments\//i.test(request.baseUrl)) { // If base URL already contains /deployments/, use it as-is with api-version.
const base = request.baseUrl.replace(/\/+$/, '') if (/\/deployments\//i.test(baseUrl)) {
chatCompletionsUrl = `${base}/chat/completions?api-version=${apiVersion}` const normalizedBase = baseUrl.replace(/\/+$/, '')
} else { return `${normalizedBase}/chat/completions?api-version=${apiVersion}`
// Strip trailing /v1 or /openai/v1 if present, then build Azure path
const base = request.baseUrl.replace(/\/(openai\/)?v1\/?$/, '').replace(/\/+$/, '')
chatCompletionsUrl = `${base}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`
}
} else {
chatCompletionsUrl = `${request.baseUrl}/chat/completions`
} }
const fetchInit = { // Strip trailing /v1 or /openai/v1 if present, then build Azure path.
const normalizedBase = baseUrl
.replace(/\/(openai\/)?v1\/?$/, '')
.replace(/\/+$/, '')
return `${normalizedBase}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`
}
return `${baseUrl}/chat/completions`
}
const localRetryBaseUrls = isLocal
? getLocalProviderRetryBaseUrls(request.baseUrl)
: []
let activeBaseUrl = request.baseUrl
let chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
const attemptedLocalBaseUrls = new Set<string>([activeBaseUrl])
let didRetryWithoutTools = false
const promoteNextLocalBaseUrl = (
reason: 'endpoint_not_found' | 'localhost_resolution_failed',
): boolean => {
for (const candidateBaseUrl of localRetryBaseUrls) {
if (attemptedLocalBaseUrls.has(candidateBaseUrl)) {
continue
}
const previousUrl = chatCompletionsUrl
attemptedLocalBaseUrls.add(candidateBaseUrl)
activeBaseUrl = candidateBaseUrl
chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
logForDebugging(
`[OpenAIShim] self-heal retry reason=${reason} method=POST from=${redactUrlForDiagnostics(previousUrl)} to=${redactUrlForDiagnostics(chatCompletionsUrl)} model=${request.resolvedModel}`,
{ level: 'warn' },
)
return true
}
return false
}
let serializedBody = JSON.stringify(body)
const refreshSerializedBody = (): void => {
serializedBody = JSON.stringify(body)
}
const buildFetchInit = () => ({
method: 'POST' as const, method: 'POST' as const,
headers, headers,
body: JSON.stringify(body), body: serializedBody,
signal: options?.signal, signal: options?.signal,
})
const maxSelfHealAttempts = isLocal
? localRetryBaseUrls.length + 1
: 0
const maxAttempts = (isGithub ? GITHUB_429_MAX_RETRIES : 1) + maxSelfHealAttempts
const throwClassifiedTransportError = (
error: unknown,
requestUrl: string,
preclassifiedFailure?: ReturnType<typeof classifyOpenAINetworkFailure>,
): never => {
if (options?.signal?.aborted) {
throw error
}
const failure =
preclassifiedFailure ??
classifyOpenAINetworkFailure(error, {
url: requestUrl,
})
const redactedUrl = redactUrlForDiagnostics(requestUrl)
const safeMessage =
redactSecretValueForDisplay(
failure.message,
process.env as SecretValueSource,
) || 'Request failed'
logForDebugging(
`[OpenAIShim] transport failure category=${failure.category} retryable=${failure.retryable} code=${failure.code ?? 'unknown'} method=POST url=${redactedUrl} model=${request.resolvedModel} message=${safeMessage}`,
{ level: 'warn' },
)
throw APIError.generate(
503,
undefined,
buildOpenAICompatibilityErrorMessage(
`OpenAI API transport error: ${safeMessage}${failure.code ? ` (code=${failure.code})` : ''}`,
failure,
),
new Headers(),
)
}
const throwClassifiedHttpError = (
status: number,
errorBody: string,
parsedBody: object | undefined,
responseHeaders: Headers,
requestUrl: string,
rateHint = '',
preclassifiedFailure?: ReturnType<typeof classifyOpenAIHttpFailure>,
): never => {
const failure =
preclassifiedFailure ??
classifyOpenAIHttpFailure({
status,
body: errorBody,
})
const redactedUrl = redactUrlForDiagnostics(requestUrl)
logForDebugging(
`[OpenAIShim] request failed category=${failure.category} retryable=${failure.retryable} status=${status} method=POST url=${redactedUrl} model=${request.resolvedModel}`,
{ level: 'warn' },
)
throw APIError.generate(
status,
parsedBody,
buildOpenAICompatibilityErrorMessage(
`OpenAI API error ${status}: ${errorBody}${rateHint}`,
failure,
),
responseHeaders,
)
} }
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
let response: Response | undefined let response: Response | undefined
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
response = await fetch(chatCompletionsUrl, fetchInit) try {
response = await fetchWithProxyRetry(
chatCompletionsUrl,
buildFetchInit(),
)
} catch (error) {
const isAbortError =
options?.signal?.aborted === true ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
(typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError')
if (isAbortError) {
throw error
}
const failure = classifyOpenAINetworkFailure(error, {
url: chatCompletionsUrl,
})
if (
isLocal &&
failure.category === 'localhost_resolution_failed' &&
promoteNextLocalBaseUrl('localhost_resolution_failed')
) {
continue
}
throwClassifiedTransportError(error, chatCompletionsUrl, failure)
}
if (response.ok) { if (response.ok) {
return response return response
} }
if ( if (
isGithub && isGithub &&
response.status === 429 && response.status === 429 &&
@@ -1408,34 +1684,87 @@ class OpenAIShimMessages {
} }
} }
const responsesResponse = await fetch(responsesUrl, { let responsesResponse: Response
try {
responsesResponse = await fetchWithProxyRetry(responsesUrl, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(responsesBody), body: JSON.stringify(responsesBody),
signal: options?.signal, signal: options?.signal,
}) })
} catch (error) {
throwClassifiedTransportError(error, responsesUrl)
}
if (responsesResponse.ok) { if (responsesResponse.ok) {
return responsesResponse return responsesResponse
} }
const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error') const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error')
const responsesFailure = classifyOpenAIHttpFailure({
status: responsesResponse.status,
body: responsesErrorBody,
})
let responsesErrorResponse: object | undefined let responsesErrorResponse: object | undefined
try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ } try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ }
throw APIError.generate( throwClassifiedHttpError(
responsesResponse.status, responsesResponse.status,
responsesErrorBody,
responsesErrorResponse, responsesErrorResponse,
`OpenAI API error ${responsesResponse.status}: ${responsesErrorBody}`,
responsesResponse.headers, responsesResponse.headers,
responsesUrl,
'',
responsesFailure,
) )
} }
} }
const failure = classifyOpenAIHttpFailure({
status: response.status,
body: errorBody,
})
if (
isLocal &&
failure.category === 'endpoint_not_found' &&
promoteNextLocalBaseUrl('endpoint_not_found')
) {
continue
}
const hasToolsPayload =
Array.isArray(body.tools) &&
body.tools.length > 0
if (
!didRetryWithoutTools &&
failure.category === 'tool_call_incompatible' &&
shouldAttemptLocalToollessRetry({
baseUrl: activeBaseUrl,
hasTools: hasToolsPayload,
})
) {
didRetryWithoutTools = true
delete body.tools
delete body.tool_choice
refreshSerializedBody()
logForDebugging(
`[OpenAIShim] self-heal retry reason=tool_call_incompatible mode=toolless method=POST url=${redactUrlForDiagnostics(chatCompletionsUrl)} model=${request.resolvedModel}`,
{ level: 'warn' },
)
continue
}
let errorResponse: object | undefined let errorResponse: object | undefined
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ } try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
throw APIError.generate( throwClassifiedHttpError(
response.status, response.status,
errorBody,
errorResponse, errorResponse,
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
response.headers as unknown as Headers, response.headers as unknown as Headers,
chatCompletionsUrl,
rateHint,
failure,
) )
} }
@@ -1492,7 +1821,7 @@ class OpenAIShimMessages {
if (typeof rawContent === 'string' && rawContent) { if (typeof rawContent === 'string' && rawContent) {
content.push({ content.push({
type: 'text', type: 'text',
text: stripLeakedReasoningPreamble(rawContent), text: stripThinkTags(rawContent),
}) })
} else if (Array.isArray(rawContent) && rawContent.length > 0) { } else if (Array.isArray(rawContent) && rawContent.length > 0) {
const parts: string[] = [] const parts: string[] = []
@@ -1510,7 +1839,7 @@ class OpenAIShimMessages {
if (joined) { if (joined) {
content.push({ content.push({
type: 'text', type: 'text',
text: stripLeakedReasoningPreamble(joined), text: stripThinkTags(joined),
}) })
} }
} }

View File

@@ -0,0 +1,225 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as realOs from 'node:os'
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
describe('resolveCodexApiCredentials with secure storage', () => {
afterEach(() => {
mock.restore()
})
test('loads Codex credentials from OpenClaude secure storage', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
apiKey: 'codex-api-key-token',
accessToken: 'header.payload.signature',
accountId: 'acct_secure',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-secure-storage'
)
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('codex-api-key-token')
expect(credentials.accountId).toBe('acct_secure')
expect(credentials.source).toBe('secure-storage')
})
test('prefers explicit env credentials over secure storage', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
accessToken: 'stored-token',
accountId: 'acct_stored',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-env-precedence'
)
const credentials = resolveCodexApiCredentials({
CODEX_API_KEY: 'env-token',
CHATGPT_ACCOUNT_ID: 'acct_env',
} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('env-token')
expect(credentials.accountId).toBe('acct_env')
expect(credentials.source).toBe('env')
})
test('parses nested chatgpt_account_id from a CODEX_API_KEY JWT', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => undefined,
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-env-nested-account'
)
const credentials = resolveCodexApiCredentials({
CODEX_API_KEY: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_nested_env',
},
}),
} as NodeJS.ProcessEnv)
expect(credentials.accountId).toBe('acct_nested_env')
expect(credentials.source).toBe('env')
})
test('parses nested chatgpt_account_id from auth.json tokens', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => undefined,
}))
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-auth-'))
const authPath = join(tempDir, 'auth.json')
writeFileSync(
authPath,
JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_nested_auth_json',
},
}),
}),
'utf8',
)
try {
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-auth-json-nested-account'
)
const credentials = resolveCodexApiCredentials({
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv)
expect(credentials.accountId).toBe('acct_nested_auth_json')
expect(credentials.source).toBe('auth.json')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('does not read default auth.json when secure storage already has Codex credentials', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
apiKey: 'codex-api-key-token',
accessToken: 'header.payload.signature',
accountId: 'acct_secure',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-secure-storage-no-auth-io'
)
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('codex-api-key-token')
expect(credentials.accountId).toBe('acct_secure')
expect(credentials.source).toBe('secure-storage')
})
test('falls back to the default auth.json when stored Codex refresh is cooling down', async () => {
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
const authJson = JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_auth_json',
},
}),
})
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
mock.module('node:os', () => ({
...realOs,
homedir: () => tempHomeDir,
}))
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => true,
readCodexCredentials: () => ({
accessToken: 'stored-token',
refreshToken: 'refresh-stored',
accountId: 'acct_stored',
lastRefreshFailureAt: Date.now(),
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-refresh-cooldown-fallback'
)
try {
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_auth_json')
expect(credentials.apiKey).not.toBe('stored-token')
} finally {
rmSync(tempHomeDir, { force: true, recursive: true })
}
})
test('preserves the stored account id when auth.json fallback lacks one', async () => {
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
const authJson = JSON.stringify({
openai_api_key: 'auth-json-access-token',
})
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
mock.module('node:os', () => ({
...realOs,
homedir: () => tempHomeDir,
}))
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => true,
readCodexCredentials: () => ({
accessToken: 'stored-token',
refreshToken: 'refresh-stored',
accountId: 'acct_stored',
lastRefreshFailureAt: Date.now(),
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-refresh-cooldown-account-id-fallback'
)
try {
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.source).toBe('auth.json')
expect(credentials.apiKey).toBe('auth-json-access-token')
expect(credentials.accountId).toBe('acct_stored')
} finally {
rmSync(tempHomeDir, { force: true, recursive: true })
}
})
})

View File

@@ -0,0 +1,107 @@
import { afterEach, expect, mock, test } from 'bun:test'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_MODEL: process.env.OPENAI_MODEL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL,
MISTRAL_MODEL: process.env.MISTRAL_MODEL,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
restoreEnv('MISTRAL_BASE_URL', originalEnv.MISTRAL_BASE_URL)
restoreEnv('MISTRAL_MODEL', originalEnv.MISTRAL_MODEL)
mock.restore()
})
test('logs a warning when OPENAI_BASE_URL is literal undefined', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'undefined'
process.env.OPENAI_MODEL = 'gpt-4o'
delete process.env.OPENAI_API_BASE
const nonce = `${Date.now()}-${Math.random()}`
const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`)
const resolved = resolveProviderRequest()
expect(resolved.baseUrl).toBe('https://api.openai.com/v1')
const warningCall = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' &&
call[0].includes('OPENAI_BASE_URL') &&
call[0].includes('"undefined"'),
)
expect(warningCall).toBeDefined()
expect(warningCall?.[1]).toEqual({ level: 'warn' })
})
test('does not warn for OPENAI_API_BASE when OPENAI_BASE_URL is active', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_USE_MISTRAL
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:11434/v1'
process.env.OPENAI_MODEL = 'qwen2.5-coder:7b'
process.env.OPENAI_API_BASE = 'undefined'
const nonce = `${Date.now()}-${Math.random()}`
const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`)
const resolved = resolveProviderRequest()
expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1')
const aliasWarning = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' &&
call[0].includes('OPENAI_API_BASE') &&
call[0].includes('"undefined"'),
)
expect(aliasWarning).toBeUndefined()
})
test('uses OPENAI_API_BASE as fallback in mistral mode when MISTRAL_BASE_URL is unset', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
delete process.env.CLAUDE_CODE_USE_OPENAI
process.env.CLAUDE_CODE_USE_MISTRAL = '1'
delete process.env.MISTRAL_BASE_URL
process.env.MISTRAL_MODEL = 'mistral-medium-latest'
process.env.OPENAI_API_BASE = 'http://127.0.0.1:11434/v1'
const nonce = `${Date.now()}-${Math.random()}`
const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`)
const resolved = resolveProviderRequest()
expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1')
expect(debugSpy.mock.calls).toHaveLength(0)
})

View File

@@ -2,8 +2,10 @@ import { afterEach, expect, test } from 'bun:test'
import { import {
getAdditionalModelOptionsCacheScope, getAdditionalModelOptionsCacheScope,
getLocalProviderRetryBaseUrls,
isLocalProviderUrl, isLocalProviderUrl,
resolveProviderRequest, resolveProviderRequest,
shouldAttemptLocalToollessRetry,
} from './providerConfig.js' } from './providerConfig.js'
const originalEnv = { const originalEnv = {
@@ -83,3 +85,42 @@ test('skips local model cache scope for remote openai-compatible providers', ()
expect(getAdditionalModelOptionsCacheScope()).toBeNull() expect(getAdditionalModelOptionsCacheScope()).toBeNull()
}) })
test('derives local retry base URLs with /v1 and loopback fallback candidates', () => {
expect(getLocalProviderRetryBaseUrls('http://localhost:11434')).toEqual([
'http://localhost:11434/v1',
'http://127.0.0.1:11434',
'http://127.0.0.1:11434/v1',
])
})
test('does not derive local retry base URLs for remote providers', () => {
expect(getLocalProviderRetryBaseUrls('https://api.openai.com/v1')).toEqual([])
})
test('enables local toolless retry for likely Ollama endpoints with tools', () => {
expect(
shouldAttemptLocalToollessRetry({
baseUrl: 'http://localhost:11434/v1',
hasTools: true,
}),
).toBe(true)
})
test('disables local toolless retry when no tools are present', () => {
expect(
shouldAttemptLocalToollessRetry({
baseUrl: 'http://localhost:11434/v1',
hasTools: false,
}),
).toBe(false)
})
test('disables local toolless retry for non-Ollama local endpoints', () => {
expect(
shouldAttemptLocalToollessRetry({
baseUrl: 'http://localhost:1234/v1',
hasTools: true,
}),
).toBe(false)
})

View File

@@ -0,0 +1,107 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveRuntimeCodexCredentials } from './providerConfig.js'
afterEach(() => {
mock.restore()
})
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
test('runtime credential resolution honors explicit auth.json over stored secure-storage tokens', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-explicit-auth-'))
const authPath = join(tempDir, 'auth.json')
writeFileSync(
authPath,
JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_explicit_auth_json',
},
}),
}),
'utf8',
)
try {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-api-key',
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_explicit_auth_json')
expect(credentials.apiKey).not.toBe('stored-api-key')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('runtime credential resolution preserves an explicit auth.json path even when it is missing', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-missing-auth-'))
const authPath = join(tempDir, 'missing-auth.json')
try {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-api-key',
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(credentials.source).toBe('none')
expect(credentials.authPath).toBe(authPath)
expect(credentials.apiKey).toBe('')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('runtime credential resolution avoids sync secure-storage reads when async credentials are provided', async () => {
let syncReadCalled = false
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => {
syncReadCalled = true
throw new Error('sync secure-storage read should not run in runtime resolution')
},
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveRuntimeCodexCredentials } = await import(
'./providerConfig.js?runtime-no-sync-secure-storage'
)
const credentials = resolveRuntimeCodexCredentials({
env: {} as NodeJS.ProcessEnv,
storedCredentials: {
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(syncReadCalled).toBe(false)
expect(credentials.source).toBe('secure-storage')
expect(credentials.apiKey).toBe('stored-access-token')
expect(credentials.accountId).toBe('acct_stored')
})

View File

@@ -3,13 +3,25 @@ import { isIP } from 'node:net'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import {
isCodexRefreshFailureCoolingDown,
readCodexCredentials,
type CodexCredentialBlob,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isEnvTruthy } from '../../utils/envUtils.js' import { isEnvTruthy } from '../../utils/envUtils.js'
import {
asTrimmedString,
parseChatgptAccountId,
} from './codexOAuthShared.js'
import { DEFAULT_GEMINI_BASE_URL } from 'src/utils/providerProfile.js'
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
/** Default GitHub Copilot API model when user selects copilot / github:copilot */ /** Default GitHub Copilot API model when user selects copilot / github:copilot */
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o' export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o'
const warnedUndefinedEnvNames = new Set<string>()
const CODEX_ALIAS_MODELS: Record< const CODEX_ALIAS_MODELS: Record<
string, string,
@@ -60,6 +72,8 @@ const CODEX_ALIAS_MODELS: Record<
type CodexAlias = keyof typeof CODEX_ALIAS_MODELS type CodexAlias = keyof typeof CODEX_ALIAS_MODELS
type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'
const OPENAI_CODEX_SHORTCUT_ALIASES = new Set(['codexplan', 'codexspark'])
export type ProviderTransport = 'chat_completions' | 'codex_responses' export type ProviderTransport = 'chat_completions' | 'codex_responses'
export type ResolvedProviderRequest = { export type ResolvedProviderRequest = {
@@ -76,7 +90,7 @@ export type ResolvedCodexCredentials = {
apiKey: string apiKey: string
accountId?: string accountId?: string
authPath?: string authPath?: string
source: 'env' | 'auth.json' | 'none' source: 'env' | 'secure-storage' | 'auth.json' | 'none'
} }
type ModelDescriptor = { type ModelDescriptor = {
@@ -112,19 +126,39 @@ function isPrivateIpv6Address(hostname: string): boolean {
return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80 return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80
} }
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
// Reads an env-var-style string intended as a URL or path, rejecting both // Reads an env-var-style string intended as a URL or path, rejecting both
// empty strings and the literal string "undefined" that Windows shells can // empty strings and the literal string "undefined" that Windows shells can
// write when a variable is unset-then-referenced without quotes (issue #336). // write when a variable is unset-then-referenced without quotes (issue #336).
function asEnvUrl(value: string | undefined): string | undefined { function asEnvUrl(value: string | undefined): string | undefined {
if (!value) return undefined if (!value) return undefined
const trimmed = value.trim() const trimmed = value.trim()
if (!trimmed || trimmed === 'undefined') return undefined if (!trimmed) return undefined
if (trimmed === 'undefined') {
return undefined
}
return trimmed
}
function asNamedEnvUrl(
value: string | undefined,
envName: string,
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
if (trimmed === 'undefined') {
if (!warnedUndefinedEnvNames.has(envName)) {
warnedUndefinedEnvNames.add(envName)
logForDebugging(
`[provider-config] Environment variable ${envName} is the literal string "undefined"; ignoring it.`,
{ level: 'warn' },
)
}
return undefined
}
return trimmed return trimmed
} }
@@ -149,23 +183,6 @@ function readNestedString(
return undefined return undefined
} }
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
const parts = token.split('.')
if (parts.length < 2) return undefined
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const json = Buffer.from(padded, 'base64').toString('utf8')
const parsed = JSON.parse(json)
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined { function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined {
if (!value) return undefined if (!value) return undefined
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
@@ -220,6 +237,12 @@ export function isCodexAlias(model: string): boolean {
return base in CODEX_ALIAS_MODELS return base in CODEX_ALIAS_MODELS
} }
function isOpenAICodexShortcutAlias(model: string): boolean {
const normalized = model.trim().toLowerCase()
const base = normalized.split('?', 1)[0] ?? normalized
return OPENAI_CODEX_SHORTCUT_ALIASES.has(base)
}
export function shouldUseCodexTransport( export function shouldUseCodexTransport(
model: string, model: string,
baseUrl: string | undefined, baseUrl: string | undefined,
@@ -282,6 +305,101 @@ export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
} }
} }
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '')
}
function normalizePathWithV1(pathname: string): string {
const trimmed = trimTrailingSlash(pathname)
if (!trimmed || trimmed === '/') {
return '/v1'
}
if (trimmed.toLowerCase().endsWith('/v1')) {
return trimmed
}
return `${trimmed}/v1`
}
function isLikelyOllamaEndpoint(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl)
const hostname = parsed.hostname.toLowerCase()
const pathname = parsed.pathname.toLowerCase()
if (parsed.port === '11434') {
return true
}
return (
hostname.includes('ollama') ||
pathname.includes('ollama')
)
} catch {
return false
}
}
export function getLocalProviderRetryBaseUrls(baseUrl: string): string[] {
if (!isLocalProviderUrl(baseUrl)) {
return []
}
try {
const parsed = new URL(baseUrl)
const original = trimTrailingSlash(parsed.toString())
const seen = new Set<string>([original])
const candidates: string[] = []
const addCandidate = (hostname: string, pathname: string): void => {
const next = new URL(parsed.toString())
next.hostname = hostname
next.pathname = pathname
next.search = ''
next.hash = ''
const normalized = trimTrailingSlash(next.toString())
if (seen.has(normalized)) {
return
}
seen.add(normalized)
candidates.push(normalized)
}
const v1Pathname = normalizePathWithV1(parsed.pathname)
if (v1Pathname !== trimTrailingSlash(parsed.pathname)) {
addCandidate(parsed.hostname, v1Pathname)
}
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (hostname === 'localhost' || hostname === '::1') {
addCandidate('127.0.0.1', parsed.pathname || '/')
addCandidate('127.0.0.1', v1Pathname)
}
return candidates
} catch {
return []
}
}
export function shouldAttemptLocalToollessRetry(options: {
baseUrl: string
hasTools: boolean
}): boolean {
if (!options.hasTools) {
return false
}
if (!isLocalProviderUrl(options.baseUrl)) {
return false
}
return isLikelyOllamaEndpoint(options.baseUrl)
}
export function isCodexBaseUrl(baseUrl: string | undefined): boolean { export function isCodexBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false if (!baseUrl) return false
try { try {
@@ -359,20 +477,80 @@ export function resolveProviderRequest(options?: {
}): ResolvedProviderRequest { }): ResolvedProviderRequest {
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL) const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
const isGeminiMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const requestedModel = const requestedModel =
options?.model?.trim() || options?.model?.trim() ||
(isMistralMode (isMistralMode
? process.env.MISTRAL_MODEL?.trim() ? process.env.MISTRAL_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) || : process.env.OPENAI_MODEL?.trim()) ||
(isGeminiMode
? process.env.GEMINI_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
options?.fallbackModel?.trim() || options?.fallbackModel?.trim() ||
(isGithubMode ? 'github:copilot' : 'gpt-4o') (isGithubMode ? 'github:copilot' : 'gpt-4o')
const descriptor = parseModelDescriptor(requestedModel) const descriptor = parseModelDescriptor(requestedModel)
const rawBaseUrl = const explicitBaseUrl = asEnvUrl(options?.baseUrl)
asEnvUrl(options?.baseUrl) ??
asEnvUrl( const normalizedMistralEnvBaseUrl = asNamedEnvUrl(
isMistralMode ? (process.env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL) : process.env.OPENAI_BASE_URL, process.env.MISTRAL_BASE_URL,
) ?? 'MISTRAL_BASE_URL',
asEnvUrl(process.env.OPENAI_API_BASE) )
const normalizedGeminiEnvBaseUrl = asNamedEnvUrl(
process.env.GEMINI_BASE_URL,
'GEMINI_BASE_URL',
)
const primaryEnvBaseUrl = isMistralMode
? normalizedMistralEnvBaseUrl
: isGeminiMode
? normalizedGeminiEnvBaseUrl
: asNamedEnvUrl(process.env.OPENAI_BASE_URL, 'OPENAI_BASE_URL')
// In Mistral mode, a literal "undefined" MISTRAL_BASE_URL is treated as
// misconfiguration and falls back to OPENAI_API_BASE, then
// DEFAULT_MISTRAL_BASE_URL for a safe default endpoint.
const fallbackEnvBaseUrl = isMistralMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL
: undefined)
: isGeminiMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_GEMINI_BASE_URL
: undefined)
: (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE')
: undefined)
const envBaseUrlRaw =
explicitBaseUrl ??
primaryEnvBaseUrl ??
fallbackEnvBaseUrl
const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel)
const envBaseUrl =
isCodexModelForGithub && envBaseUrlRaw && getGithubEndpointType(envBaseUrlRaw) === 'custom'
? undefined
: envBaseUrlRaw
const rawBaseUrl = explicitBaseUrl ?? envBaseUrl
const shellModel = process.env.OPENAI_MODEL?.trim() ?? ''
const envIsCodexShortcut = isOpenAICodexShortcutAlias(shellModel)
const envResolvedCodexModel = envIsCodexShortcut
? parseModelDescriptor(shellModel).baseModel
: null
const requestedMatchesEnvCodexShortcut =
Boolean(options?.model) &&
Boolean(envResolvedCodexModel) &&
descriptor.baseModel === envResolvedCodexModel
const isCodexAliasModel =
isOpenAICodexShortcutAlias(requestedModel) || requestedMatchesEnvCodexShortcut
const hasUserSetBaseUrl = rawBaseUrl && rawBaseUrl !== DEFAULT_OPENAI_BASE_URL
const finalBaseUrl =
!isGithubMode && isCodexAliasModel && !hasUserSetBaseUrl
? DEFAULT_CODEX_BASE_URL
: rawBaseUrl
const githubEndpointType = isGithubMode const githubEndpointType = isGithubMode
? getGithubEndpointType(rawBaseUrl) ? getGithubEndpointType(rawBaseUrl)
@@ -386,7 +564,7 @@ export function resolveProviderRequest(options?: {
: requestedModel : requestedModel
const transport: ProviderTransport = const transport: ProviderTransport =
shouldUseCodexTransport(requestedModel, rawBaseUrl) || shouldUseCodexTransport(requestedModel, finalBaseUrl) ||
(isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel)) (isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel))
? 'codex_responses' ? 'codex_responses'
: 'chat_completions' : 'chat_completions'
@@ -410,7 +588,7 @@ export function resolveProviderRequest(options?: {
requestedModel, requestedModel,
resolvedModel, resolvedModel,
baseUrl: baseUrl:
(rawBaseUrl ?? (finalBaseUrl ??
(isGithubCopilot && transport === 'codex_responses' (isGithubCopilot && transport === 'codex_responses'
? GITHUB_COPILOT_BASE_URL ? GITHUB_COPILOT_BASE_URL
: (isGithubMode : (isGithubMode
@@ -458,18 +636,6 @@ export function resolveCodexAuthPath(
return join(homedir(), '.codex', 'auth.json') return join(homedir(), '.codex', 'auth.json')
} }
export function parseChatgptAccountId(
token: string | undefined,
): string | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const fromClaim = asTrimmedString(
payload?.['https://api.openai.com/auth.chatgpt_account_id'],
)
if (fromClaim) return fromClaim
return asTrimmedString(payload?.chatgpt_account_id)
}
function loadCodexAuthJson( function loadCodexAuthJson(
authPath: string, authPath: string,
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
@@ -485,8 +651,97 @@ function loadCodexAuthJson(
} }
} }
export function resolveCodexApiCredentials( function resolveCodexAuthJsonCredentials(options: {
env: NodeJS.ProcessEnv = process.env, authJson: Record<string, unknown> | undefined
authPath: string
envAccountId?: string
missingSource?: ResolvedCodexCredentials['source']
}): ResolvedCodexCredentials {
const { authJson, authPath, envAccountId } = options
if (!authJson) {
return {
apiKey: '',
authPath,
source: options.missingSource ?? 'none',
}
}
const apiKey = readNestedString(authJson, [
['openai_api_key'],
['openaiApiKey'],
['access_token'],
['accessToken'],
['tokens', 'access_token'],
['tokens', 'accessToken'],
['auth', 'access_token'],
['auth', 'accessToken'],
['token', 'access_token'],
['token', 'accessToken'],
])
// OIDC identity tokens can carry the ChatGPT account id, but they are not
// valid bearer credentials for Codex API requests.
const idToken = readNestedString(authJson, [
['id_token'],
['idToken'],
['tokens', 'id_token'],
['tokens', 'idToken'],
])
const accountId =
envAccountId ??
readNestedString(authJson, [
['account_id'],
['accountId'],
['tokens', 'account_id'],
['tokens', 'accountId'],
['auth', 'account_id'],
['auth', 'accountId'],
]) ??
parseChatgptAccountId(apiKey) ??
parseChatgptAccountId(idToken)
if (!apiKey) {
return {
apiKey: '',
accountId,
authPath,
source: options.missingSource ?? 'none',
}
}
return {
apiKey,
accountId,
authPath,
source: 'auth.json',
}
}
export function resolveStoredCodexCredentials(options: {
storedCredentials: Pick<
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
envAccountId?: string
}): ResolvedCodexCredentials {
const { storedCredentials, envAccountId } = options
return {
apiKey: storedCredentials.apiKey ?? storedCredentials.accessToken,
accountId:
envAccountId ??
storedCredentials.accountId ??
parseChatgptAccountId(storedCredentials.idToken) ??
parseChatgptAccountId(storedCredentials.accessToken),
source: 'secure-storage',
}
}
function resolveEnvOrAuthJsonCodexCredentials(
env: NodeJS.ProcessEnv,
options?: {
explicitAuthPathOnly?: boolean
},
): ResolvedCodexCredentials { ): ResolvedCodexCredentials {
const envApiKey = asTrimmedString(env.CODEX_API_KEY) const envApiKey = asTrimmedString(env.CODEX_API_KEY)
const envAccountId = const envAccountId =
@@ -501,55 +756,127 @@ export function resolveCodexApiCredentials(
} }
} }
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
if (!explicitAuthPathConfigured && options?.explicitAuthPathOnly) {
return {
apiKey: '',
accountId: envAccountId,
source: 'none',
}
}
const authPath = resolveCodexAuthPath(env) const authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath) const authJson = loadCodexAuthJson(authPath)
if (!authJson) { return resolveCodexAuthJsonCredentials({
return { authJson,
apiKey: '',
authPath, authPath,
source: 'none', envAccountId,
})
}
export function resolveRuntimeCodexCredentials(options?: {
env?: NodeJS.ProcessEnv
storedCredentials?: Pick<
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
}): ResolvedCodexCredentials {
const env = options?.env ?? process.env
const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, {
explicitAuthPathOnly: true,
})
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
const hasStoredCredentialsOption = Boolean(
options &&
Object.prototype.hasOwnProperty.call(options, 'storedCredentials'),
)
if (
explicitAuthPathConfigured ||
explicitCredentials.source === 'env' ||
explicitCredentials.source === 'auth.json'
) {
return explicitCredentials
}
if (options?.storedCredentials?.accessToken) {
return resolveStoredCodexCredentials({
storedCredentials: options.storedCredentials,
envAccountId:
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID),
})
}
if (hasStoredCredentialsOption) {
return resolveEnvOrAuthJsonCodexCredentials(env)
}
return resolveCodexApiCredentials(env)
}
export function resolveCodexApiCredentials(
env: NodeJS.ProcessEnv = process.env,
): ResolvedCodexCredentials {
const envAccountId =
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID)
const envOrExplicitAuthJsonCredentials = resolveEnvOrAuthJsonCodexCredentials(
env,
{
explicitAuthPathOnly: true,
},
)
if (
envOrExplicitAuthJsonCredentials.source === 'env' ||
envOrExplicitAuthJsonCredentials.source === 'auth.json' ||
envOrExplicitAuthJsonCredentials.authPath
) {
return envOrExplicitAuthJsonCredentials
}
const storedCredentials = readCodexCredentials()
if (storedCredentials?.accessToken) {
const resolvedStoredCredentials = resolveStoredCodexCredentials({
storedCredentials,
envAccountId,
})
const shouldCheckDefaultAuthJson =
!resolvedStoredCredentials.accountId ||
isCodexRefreshFailureCoolingDown(storedCredentials)
if (!shouldCheckDefaultAuthJson) {
return resolvedStoredCredentials
}
const authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath)
const resolvedAuthJsonCredentials = resolveCodexAuthJsonCredentials({
authJson,
authPath,
envAccountId,
})
if (resolvedAuthJsonCredentials.apiKey) {
return {
...resolvedAuthJsonCredentials,
accountId:
resolvedAuthJsonCredentials.accountId ??
resolvedStoredCredentials.accountId,
} }
} }
const apiKey = readNestedString(authJson, [ return resolvedStoredCredentials
['access_token'],
['accessToken'],
['tokens', 'access_token'],
['tokens', 'accessToken'],
['auth', 'access_token'],
['auth', 'accessToken'],
['token', 'access_token'],
['token', 'accessToken'],
['tokens', 'id_token'],
['tokens', 'idToken'],
])
const accountId =
envAccountId ??
readNestedString(authJson, [
['account_id'],
['accountId'],
['tokens', 'account_id'],
['tokens', 'accountId'],
['auth', 'account_id'],
['auth', 'accountId'],
]) ??
parseChatgptAccountId(apiKey)
if (!apiKey) {
return {
apiKey: '',
accountId,
authPath,
source: 'none',
}
} }
return { return resolveEnvOrAuthJsonCodexCredentials(env)
apiKey,
accountId,
authPath,
source: 'auth.json',
}
} }
export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined { export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined {
@@ -559,3 +886,18 @@ export function getReasoningEffortForModel(model: string): ReasoningEffort | und
const aliasConfig = CODEX_ALIAS_MODELS[alias] const aliasConfig = CODEX_ALIAS_MODELS[alias]
return aliasConfig?.reasoningEffort return aliasConfig?.reasoningEffort
} }
export function supportsCodexReasoningEffort(model: string): boolean {
const normalized = model.trim().toLowerCase()
const base = normalized.split('?', 1)[0] ?? normalized
if (base === 'gpt-5.3-codex-spark' || base === 'codexspark') {
return false
}
if (getReasoningEffortForModel(base) !== undefined) {
return true
}
return /^gpt-5(?:[.-]|$)/.test(base)
}

View File

@@ -1,46 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
looksLikeLeakedReasoningPrefix,
shouldBufferPotentialReasoningPrefix,
stripLeakedReasoningPreamble,
} from './reasoningLeakSanitizer.ts'
describe('reasoning leak sanitizer', () => {
test('strips explicit internal reasoning preambles', () => {
const text =
'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?'
expect(looksLikeLeakedReasoningPrefix(text)).toBe(true)
expect(stripLeakedReasoningPreamble(text)).toBe(
'Hey! How can I help you today?',
)
})
test('does not strip normal user-facing advice that mentions "the user should"', () => {
const text =
'The user should reset their password immediately.\n\nHere are the steps...'
expect(looksLikeLeakedReasoningPrefix(text)).toBe(false)
expect(shouldBufferPotentialReasoningPrefix(text)).toBe(false)
expect(stripLeakedReasoningPreamble(text)).toBe(text)
})
test('does not strip legitimate first-person advice about responding to an incident', () => {
const text =
'I need to respond to this security incident immediately. The system is compromised.\n\nHere are the remediation steps...'
expect(looksLikeLeakedReasoningPrefix(text)).toBe(false)
expect(shouldBufferPotentialReasoningPrefix(text)).toBe(false)
expect(stripLeakedReasoningPreamble(text)).toBe(text)
})
test('does not strip legitimate first-person advice about answering a support ticket', () => {
const text =
'I need to answer the support ticket before end of day. The customer is waiting.\n\nHere is the response I drafted...'
expect(looksLikeLeakedReasoningPrefix(text)).toBe(false)
expect(shouldBufferPotentialReasoningPrefix(text)).toBe(false)
expect(stripLeakedReasoningPreamble(text)).toBe(text)
})
})

View File

@@ -1,54 +0,0 @@
const EXPLICIT_REASONING_START_RE =
/^\s*(i should\b|i need to\b|let me think\b|the task\b|the request\b)/i
const EXPLICIT_REASONING_META_RE =
/\b(user|request|question|prompt|message|task|greeting|small talk|briefly|friendly|concise)\b/i
const USER_META_START_RE =
/^\s*the user\s+(just\s+)?(said|asked|is asking|wants|wanted|mentioned|seems|appears)\b/i
const USER_REASONING_RE =
/^\s*the user\s+(just\s+)?(said|asked|is asking|wants|wanted|mentioned|seems|appears)\b[\s\S]*\b(i should|i need to|let me think|respond|reply|answer|greeting|small talk|briefly|friendly|concise)\b/i
export function shouldBufferPotentialReasoningPrefix(text: string): boolean {
const normalized = text.trim()
if (!normalized) return false
if (looksLikeLeakedReasoningPrefix(normalized)) {
return true
}
const hasParagraphBoundary = /\n\s*\n/.test(normalized)
if (hasParagraphBoundary) {
return false
}
return (
EXPLICIT_REASONING_START_RE.test(normalized) ||
USER_META_START_RE.test(normalized)
)
}
export function looksLikeLeakedReasoningPrefix(text: string): boolean {
const normalized = text.trim()
if (!normalized) return false
return (
(EXPLICIT_REASONING_START_RE.test(normalized) &&
EXPLICIT_REASONING_META_RE.test(normalized)) ||
USER_REASONING_RE.test(normalized)
)
}
export function stripLeakedReasoningPreamble(text: string): string {
const normalized = text.replace(/\r\n/g, '\n')
const parts = normalized.split(/\n\s*\n/)
if (parts.length < 2) return text
const first = parts[0]?.trim() ?? ''
if (!looksLikeLeakedReasoningPrefix(first)) {
return text
}
const remainder = parts.slice(1).join('\n\n').trim()
return remainder || text
}

View File

@@ -0,0 +1,183 @@
import { describe, expect, test } from 'bun:test'
import {
createThinkTagFilter,
stripThinkTags,
} from './thinkTagSanitizer.ts'
describe('stripThinkTags — whole-text cleanup', () => {
test('strips closed think pair', () => {
expect(stripThinkTags('<think>reasoning</think>Hello')).toBe('Hello')
})
test('strips closed thinking pair', () => {
expect(stripThinkTags('<thinking>x</thinking>Out')).toBe('Out')
})
test('strips closed reasoning pair', () => {
expect(stripThinkTags('<reasoning>x</reasoning>Out')).toBe('Out')
})
test('strips REASONING_SCRATCHPAD pair', () => {
expect(stripThinkTags('<REASONING_SCRATCHPAD>plan</REASONING_SCRATCHPAD>Answer'))
.toBe('Answer')
})
test('is case-insensitive', () => {
expect(stripThinkTags('<THINKING>x</THINKING>out')).toBe('out')
expect(stripThinkTags('<Think>x</Think>out')).toBe('out')
})
test('handles attributes on open tag', () => {
expect(stripThinkTags('<think id="plan-1">reason</think>ok')).toBe('ok')
})
test('strips unterminated open tag at block boundary', () => {
expect(stripThinkTags('<think>reasoning that never closes')).toBe('')
})
test('strips unterminated open tag after newline', () => {
// Block-boundary match consumes the leading newline, same as hermes.
expect(stripThinkTags('Answer: 42\n<think>second-guess myself'))
.toBe('Answer: 42')
})
test('strips orphan close tag', () => {
expect(stripThinkTags('trailing </think>done')).toBe('trailing done')
})
test('strips multiple blocks', () => {
expect(stripThinkTags('<think>a</think>B<think>c</think>D')).toBe('BD')
})
test('handles reasoning mid-response after content', () => {
expect(stripThinkTags('Answer: 42\n<think>double-check</think>\nDone'))
.toBe('Answer: 42\n\nDone')
})
test('handles nested-looking tags (lazy match + orphan cleanup)', () => {
expect(stripThinkTags('<think><think>x</think></think>y')).toBe('y')
})
test('preserves legitimate non-think tags', () => {
expect(stripThinkTags('use <div> and <span>')).toBe('use <div> and <span>')
})
test('preserves text without any tags', () => {
expect(stripThinkTags('Hello, world. I should respond briefly.')).toBe(
'Hello, world. I should respond briefly.',
)
})
test('handles empty input', () => {
expect(stripThinkTags('')).toBe('')
})
})
describe('createThinkTagFilter — streaming state machine', () => {
test('passes through plain text', () => {
const f = createThinkTagFilter()
expect(f.feed('Hello, ')).toBe('Hello, ')
expect(f.feed('world!')).toBe('world!')
expect(f.flush()).toBe('')
})
test('strips a complete think block in one chunk', () => {
const f = createThinkTagFilter()
expect(f.feed('pre<think>reason</think>post')).toBe('prepost')
expect(f.flush()).toBe('')
})
test('handles open tag split across deltas', () => {
const f = createThinkTagFilter()
expect(f.feed('before<th')).toBe('before')
expect(f.feed('ink>reason</think>after')).toBe('after')
expect(f.flush()).toBe('')
})
test('handles close tag split across deltas', () => {
const f = createThinkTagFilter()
expect(f.feed('<think>reason</th')).toBe('')
expect(f.feed('ink>keep')).toBe('keep')
expect(f.flush()).toBe('')
})
test('handles tag split on bare < boundary', () => {
const f = createThinkTagFilter()
expect(f.feed('leading <')).toBe('leading ')
expect(f.feed('think>inner</think>tail')).toBe('tail')
expect(f.flush()).toBe('')
})
test('preserves partial non-tag < at boundary when next char rules it out', () => {
const f = createThinkTagFilter()
// "<d" — 'd' cannot start any of our tag names, so emit immediately
expect(f.feed('pre<d')).toBe('pre<d')
expect(f.feed('iv>rest')).toBe('iv>rest')
expect(f.flush()).toBe('')
})
test('case-insensitive streaming', () => {
const f = createThinkTagFilter()
expect(f.feed('<THINKING>x</THINKING>out')).toBe('out')
expect(f.flush()).toBe('')
})
test('unterminated open tag — flush drops remainder', () => {
const f = createThinkTagFilter()
expect(f.feed('<think>reasoning with no close ')).toBe('')
expect(f.feed('and more reasoning')).toBe('')
expect(f.flush()).toBe('')
expect(f.isInsideBlock()).toBe(false)
})
test('multiple blocks in single feed', () => {
const f = createThinkTagFilter()
expect(f.feed('<think>a</think>B<think>c</think>D')).toBe('BD')
expect(f.flush()).toBe('')
})
test('flush after clean stream emits nothing extra', () => {
const f = createThinkTagFilter()
expect(f.feed('complete message')).toBe('complete message')
expect(f.flush()).toBe('')
})
test('flush of bare < at end emits it (not a tag prefix)', () => {
const f = createThinkTagFilter()
// bare '<' held back; flush emits it since it has no tag-name chars
expect(f.feed('x <')).toBe('x ')
expect(f.flush()).toBe('<')
})
test('flush of partial tag-name prefix at end drops it', () => {
const f = createThinkTagFilter()
expect(f.feed('x <thi')).toBe('x ')
expect(f.flush()).toBe('')
})
test('handles attributes on streaming open tag', () => {
const f = createThinkTagFilter()
expect(f.feed('<think type="plan">reason</think>ok')).toBe('ok')
expect(f.flush()).toBe('')
})
test('mid-delta transition: content, reasoning, content', () => {
const f = createThinkTagFilter()
expect(f.feed('Answer: 42\n<think>')).toBe('Answer: 42\n')
expect(f.feed('double-check')).toBe('')
expect(f.feed('</think>\nDone')).toBe('\nDone')
expect(f.flush()).toBe('')
})
test('orphan close tag mid-stream is stripped on flush via safety-net behavior', () => {
// Filter alone treats orphan close as "we're not inside", so it emits as-is.
// Safety net (stripThinkTags on final text) removes orphans.
const f = createThinkTagFilter()
const chunk1 = f.feed('trailing ')
const chunk2 = f.feed('</think>done')
const final = chunk1 + chunk2 + f.flush()
// Orphan close appears in stream output; safety net cleans it
expect(stripThinkTags(final)).toBe('trailing done')
})
})

View File

@@ -0,0 +1,162 @@
/**
* Think-tag sanitizer for reasoning content leaks.
*
* Some OpenAI-compatible reasoning models (MiniMax M2.7, GLM-4.5/5, DeepSeek, Kimi K2,
* self-hosted vLLM builds) emit chain-of-thought inline inside the `content` field using
* XML-like tags instead of the separate `reasoning_content` channel. Example:
*
* <think>the user wants foo, let me check bar</think>Here is the answer: ...
*
* This module strips those blocks structurally (tag-based), independent of English
* phrasings. Three layers:
*
* 1. `createThinkTagFilter()` — streaming state machine. Feeds deltas, emits only
* the visible (non-reasoning) portion, and buffers partial tags across chunk
* boundaries so `</th` + `ink>` still parses correctly.
*
* 2. `stripThinkTags()` — whole-text cleanup. Removes closed pairs, unterminated
* opens at block boundaries, and orphan open/close tags. Used for non-streaming
* responses and as a safety net after stream close.
*
* 3. Flush discards buffered partial tags at stream end (false-negative bias —
* prefer losing a partial reasoning fragment over leaking it).
*/
const TAG_NAMES = [
'think',
'thinking',
'reasoning',
'thought',
'reasoning_scratchpad',
] as const
const TAG_ALT = TAG_NAMES.join('|')
const OPEN_TAG_RE = new RegExp(`<\\s*(?:${TAG_ALT})\\b[^>]*>`, 'i')
const CLOSE_TAG_RE = new RegExp(`<\\s*/\\s*(?:${TAG_ALT})\\s*>`, 'i')
const CLOSED_PAIR_RE_G = new RegExp(
`<\\s*(${TAG_ALT})\\b[^>]*>[\\s\\S]*?<\\s*/\\s*\\1\\s*>`,
'gi',
)
const UNTERMINATED_OPEN_RE = new RegExp(
`(?:^|\\n)[ \\t]*<\\s*(?:${TAG_ALT})\\b[^>]*>[\\s\\S]*$`,
'i',
)
const ORPHAN_TAG_RE_G = new RegExp(
`<\\s*/?\\s*(?:${TAG_ALT})\\b[^>]*>\\s*`,
'gi',
)
const MAX_PARTIAL_TAG = 64
/**
* Remove reasoning/thinking blocks from a complete text body.
*
* Handles:
* - Closed pairs: <think>...</think> (lazy match, anywhere in text)
* - Unterminated open tags at a block boundary: strips from the tag to end of string
* - Orphan open or close tags (no matching partner)
*
* False-negative bias: prefers leaving a few tag characters in rare edge cases over
* stripping legitimate content.
*/
export function stripThinkTags(text: string): string {
if (!text) return text
let out = text
out = out.replace(CLOSED_PAIR_RE_G, '')
out = out.replace(UNTERMINATED_OPEN_RE, '')
out = out.replace(ORPHAN_TAG_RE_G, '')
return out
}
export interface ThinkTagFilter {
feed(chunk: string): string
flush(): string
isInsideBlock(): boolean
}
/**
* Streaming state machine. Feed deltas, emits visible (non-reasoning) text.
* Handles tags split across chunk boundaries by holding back a short tail buffer
* whenever the current buffer ends with what looks like a partial tag.
*/
export function createThinkTagFilter(): ThinkTagFilter {
let inside = false
let buffer = ''
function findPartialTagStart(s: string): number {
const lastLt = s.lastIndexOf('<')
if (lastLt === -1) return -1
if (s.indexOf('>', lastLt) !== -1) return -1
const tail = s.slice(lastLt)
if (tail.length > MAX_PARTIAL_TAG) return -1
const m = /^<\s*\/?\s*([a-zA-Z_]\w*)?\s*$/.exec(tail)
if (!m) return -1
const partialName = (m[1] ?? '').toLowerCase()
if (!partialName) return lastLt
if (TAG_NAMES.some(name => name.startsWith(partialName))) return lastLt
return -1
}
function feed(chunk: string): string {
if (!chunk) return ''
buffer += chunk
let out = ''
while (buffer.length > 0) {
if (!inside) {
const open = OPEN_TAG_RE.exec(buffer)
if (open) {
out += buffer.slice(0, open.index)
buffer = buffer.slice(open.index + open[0].length)
inside = true
continue
}
const partialStart = findPartialTagStart(buffer)
if (partialStart === -1) {
out += buffer
buffer = ''
} else {
out += buffer.slice(0, partialStart)
buffer = buffer.slice(partialStart)
}
return out
}
const close = CLOSE_TAG_RE.exec(buffer)
if (close) {
buffer = buffer.slice(close.index + close[0].length)
inside = false
continue
}
const partialStart = findPartialTagStart(buffer)
if (partialStart === -1) {
buffer = ''
} else {
buffer = buffer.slice(partialStart)
}
return out
}
return out
}
function flush(): string {
const held = buffer
const wasInside = inside
buffer = ''
inside = false
if (wasInside) return ''
if (!held) return ''
if (/^<\s*\/?\s*[a-zA-Z_]/.test(held)) return ''
return held
}
return { feed, flush, isInsideBlock: () => inside }
}

View File

@@ -70,7 +70,7 @@ describe('runAutoFixCheck', () => {
test('handles timeout gracefully', async () => { test('handles timeout gracefully', async () => {
const result = await runAutoFixCheck({ const result = await runAutoFixCheck({
lint: 'sleep 10', lint: 'node -e "setTimeout(() => {}, 10000)"',
timeout: 100, timeout: 100,
cwd: '/tmp', cwd: '/tmp',

View File

@@ -46,14 +46,31 @@ async function runCommand(
const killTree = () => { const killTree = () => {
try { try {
if (!isWindows && proc.pid) { if (isWindows && proc.pid) {
// shell=true on Windows can leave child commands running unless we
// terminate the full process tree.
const killer = spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
windowsHide: true,
stdio: 'ignore',
})
killer.unref()
return
}
if (proc.pid) {
// Kill the entire process group // Kill the entire process group
process.kill(-proc.pid, 'SIGTERM') process.kill(-proc.pid, 'SIGTERM')
} else { return
proc.kill('SIGTERM')
} }
proc.kill('SIGTERM')
} catch { } catch {
// Process may have already exited // Process may have already exited; fallback to direct child kill.
try {
proc.kill('SIGTERM')
} catch {
// Ignore final fallback errors.
}
} }
} }

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'
import {
getEffectiveContextWindowSize,
getAutoCompactThreshold,
} from './autoCompact.ts'
describe('getEffectiveContextWindowSize', () => {
test('returns positive value for known models with large context windows', () => {
// claude-sonnet-4 has 200k context
const effective = getEffectiveContextWindowSize('claude-sonnet-4')
expect(effective).toBeGreaterThan(0)
})
test('never returns negative even for unknown 3P models (issue #635)', () => {
// Previously, unknown 3P models got 8k context → effective context was
// 8k minus 20k summary reservation = -12k, causing infinite auto-compact.
// Now the fallback is 128k and there's a floor, so effective is always
// at least reservedTokensForSummary + buffer.
process.env.CLAUDE_CODE_USE_OPENAI = '1'
try {
const effective = getEffectiveContextWindowSize('some-unknown-3p-model')
expect(effective).toBeGreaterThan(0)
// Must be at least summary reservation (20k) + buffer (13k) = 33k
expect(effective).toBeGreaterThanOrEqual(33_000)
} finally {
delete process.env.CLAUDE_CODE_USE_OPENAI
}
})
})
describe('getAutoCompactThreshold', () => {
test('returns positive threshold for known models', () => {
const threshold = getAutoCompactThreshold('claude-sonnet-4')
expect(threshold).toBeGreaterThan(0)
})
test('never returns negative threshold even for unknown 3P models (issue #635)', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
try {
const threshold = getAutoCompactThreshold('some-unknown-3p-model')
expect(threshold).toBeGreaterThan(0)
} finally {
delete process.env.CLAUDE_CODE_USE_OPENAI
}
})
})

View File

@@ -45,7 +45,12 @@ export function getEffectiveContextWindowSize(model: string): number {
} }
} }
return contextWindow - reservedTokensForSummary // Floor: effective context must be at least the summary reservation plus a
// usable buffer. If it goes lower, the auto-compact threshold becomes
// negative and fires on every message (issue #635).
const autocompactBuffer = 13_000 // must match AUTOCOMPACT_BUFFER_TOKENS
const effectiveContext = contextWindow - reservedTokensForSummary
return Math.max(effectiveContext, reservedTokensForSummary + autocompactBuffer)
} }
export type AutoCompactTrackingState = { export type AutoCompactTrackingState = {
@@ -105,9 +110,14 @@ export function calculateTokenWarningState(
? autoCompactThreshold ? autoCompactThreshold
: getEffectiveContextWindowSize(model) : getEffectiveContextWindowSize(model)
// Use the raw context window (without output reservation) for the percentage
// display, so users see remaining context relative to the model's full capacity.
// The threshold (which subtracts buffer) should only affect when we warn/compact,
// not what percentage we display.
const rawContextWindow = getContextWindowForModel(model, getSdkBetas())
const percentLeft = Math.max( const percentLeft = Math.max(
0, 0,
Math.round(((threshold - tokenUsage) / threshold) * 100), Math.round(((rawContextWindow - tokenUsage) / rawContextWindow) * 100),
) )
const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS

Some files were not shown because too many files have changed in this diff Show More