* feat(provider): add OpenCode Zen/Go subscription support
Add OpenCode as a first-class provider, enabling users to connect their
Zen (pay-as-you-go) and Go ($10/mo) subscriptions via the /provider command.
New integration descriptors:
- vendors/opencode.ts — OpenCode Zen vendor (41 models)
- gateways/opencode-go.ts — OpenCode Go gateway (12 models)
- brands/opencode.ts — brand descriptor
- models/opencode.ts — full model catalog (GPT, Claude, Gemini, Qwen,
GLM, Kimi, MiniMax, Grok, DeepSeek, MiMo, Nemotron)
Modified files:
- integrationArtifacts.generated.ts — register descriptors and presets
- providerProfile.ts — add OPENCODE_API_KEY env/secret key, 'opencode'
profile type, and buildLaunchEnv handler
- providerConfig.ts — add DEFAULT_OPENCODE_BASE_URL constants
Auth: OPENCODE_API_KEY env var or interactive key entry in /provider
Transport: openai-compatible (chat_completions)
Base URLs: https://opencode.ai/zen/v1 (Zen), /zen/go/v1 (Go)
* feat(provider): add [Zen]/[Go] tags to OpenCode preset labels
Add visual tags in the /provider preset selection to distinguish
OpenCode Zen (pay-as-you-go) from OpenCode Go (subscription).
* feat(provider): enable dynamic model discovery for OpenCode
Switch OpenCode vendor and Go gateway from static to hybrid model
catalog with openai-compatible discovery. Models are fetched from
/v1/models on startup and cached for 1 hour. Manual refresh is
supported via the /provider UI.
Static model list is preserved as fallback when discovery fails.
* test(provider): add comprehensive OpenCode Zen/Go test suite
97 tests across 2 files covering:
Integration tests (72 tests):
- Vendor descriptor: id, label, classification, base URL, model, auth,
transport, preset, validation, catalog, discovery, usage metadata
- Gateway descriptor: id, label, vendorId, category, base URL, model,
auth, transport, preset, catalog, discovery
- Brand descriptor: id, label, canonicalVendorId, capabilities, modelIds
- Model catalog: registration, vendor/gateway associations, required
fields, valid classifications, reasoning/coding tags, no duplicates,
model counts (41 Zen, 12 Go), modelDescriptorId consistency
- Cross-reference: brand↔model, vendor↔model, gateway↔model,
shared OPENCODE_API_KEY
- Registry validation: no errors, no preset conflicts
- Edge cases: unique ids, unique apiNames, non-empty labels, valid
contextWindow/maxOutputTokens, valid defaultModel format, validation
message content, discovery config
Profile tests (25 tests):
- Type guard: isProviderProfile('opencode'), rejects invalid values
- buildLaunchEnv: persisted env, defaults, process env precedence,
OPENCODE_API_KEY mapping, whitespace/null/undefined/empty handling,
very long keys, special characters, concurrent access, boundary
values, no credential leakage
* fix(provider): add per-model endpoint routing (P1)
Add endpointPath field to OpenAIShimTransportConfig so catalog entries
can specify which API path to use per model. This addresses the
maintainer's [P1] finding that all models were routed to
/chat/completions regardless of their upstream endpoint.
Changes:
- descriptors.ts: add endpointPath?: string to OpenAIShimTransportConfig
- openaiShim.ts: buildRequestUrl checks shimConfig.endpointPath first
- vendors/opencode.ts: add transportOverrides to 31 catalog entries
(GPT→/responses, Claude/Qwen→/messages, Gemini→/models/<id>)
+ switch to source: 'static' to prevent free models from live API
- gateways/opencode-go.ts: add transportOverrides to 4 entries
(MiniMax/Qwen→/messages) + switch to source: 'static'
- opencode.test.ts: update tests for static source, remove discovery tests
* refactor(opencode): model OpenCode Zen/Go as gateways (P2)
* docs(provider): document OpenCode setup and move badge metadata to descriptors
- Add OpenCode Zen/Go rows to README supported providers table
- Add OpenCode Zen/Go examples and OPENCODE_API_KEY to advanced-setup.md
- Add PresetBadge type to descriptor/manifest with badge propagation in
artifact generator
- Move 4 hard-coded preset badges ([FREE], [Sponsor], [Zen], [Go]) from
ProviderManager.tsx into descriptor preset metadata
- Add badge field to providerUiMetadata so UI components read from manifest
- Update integration overview docs to recommend preset.badge for future
gateways
* fix(provider): match request body to endpoint format for OpenCode /messages and /responses (P1)
Extend the openaiShim transport so that endpointPath overrides select
both the URL and the correct body/response format:
- /responses → OpenAI Responses API body (input, max_output_tokens)
- /messages → Anthropic Messages API body (content blocks, system, max_tokens)
Also fixes: abort listener leak in SSE passthrough, system prompt
content-block flattening, and removes [Zen]/[Go] badge entries (P3).
Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
* fix(provider): add Google AI SDK body/response format for OpenCode Zen Gemini models (P1)
The three Gemini models in the OpenCode Zen catalog (gemini-3.5-flash,
gemini-3.1-pro, gemini-3-flash) were sending chat-completions body to
the /models/gemini-* endpoint, which expects Google AI SDK format.
- effectiveTransport now detects /models/gemini- endpointPath → 'gemini'
- buildGeminiBody() converts Anthropic messages → Google contents[]
with role mapping, systemInstruction, generationConfig, functionDeclarations
- geminiSseToAnthropic() parses Google SSE frames → Anthropic stream events
with text deltas, functionCall tool_use, finishReason mapping
- _convertGeminiToAnthropicResponse() for non-streaming responses
- Streaming/non-streaming routing via URL detection (/models/gemini-)
- serializeBody(), hasToolsPayload, omitGeminiTools all updated
* fix: prevent OpenCode model descriptors from shadowing canonical limits
P1: Prefix all defaultModel values in opencode.ts with 'opencode-'
so the fallback findModelDescriptorForApiName() doesn't match
canonical model names. The OpenCode descriptors are still found
via catalog entry lookup when the OpenCode route is active.
P2: Add 'OpenCode Go' and 'OpenCode Zen' to PRESET_ORDER in
ProviderManager.test.tsx between 'OpenAI' and 'OpenRouter'
so navigateToPreset() sends the correct number of j keypresses.
* fix: align OpenCode Go descriptor metadata with Zen
- category: 'hosted' → 'aggregating' (both are aggregating gateways)
- add validation block with OPENCODE_API_KEY guidance
- update test assertion from 'hosted' to 'aggregating'
* fix: accept OPENAI_API_KEY as fallback in OpenCode validation
When users set up OpenCode Zen/Go via /provider, the key is saved as
OPENAI_API_KEY (via buildCompatibilityProcessEnv). The validation block
only checked OPENCODE_API_KEY, causing a startup warning even though
the runtime auth header had the key it needed.
Add OPENAI_API_KEY to validation.credentialEnvVars for both gateways,
matching the pattern used by Hicap and Gitlawb Opengateway.
* chore: trigger mergeability recheck
* feat(shim): forward effort/thinking to OpenCode Zen/Go endpoints
- buildResponsesBody: add reasoning_effort + reasoning_summary + include
- buildAnthropicMessagesBody: add thinking config (adaptive/enabled/budget)
- buildGeminiBody: add thinkingConfig with thinkingLevel mapping
- modelSupportsEffort: allow OpenCode Claude and Gemini models
- modelSupportsMaxEffort: add opus-4-7
- getAvailableEffortLevels: show standard levels for OpenCode native models
- opencode-go: add missing validation block
* feat: update OpenCode Zen and Go model counts, add new models, and enhance effort level handling
* feat: implement xhigh effort support for specific models and adjust effort level handling
* fix(effort): address reviewer feedback on xhigh + new models
- docs/advanced-setup.md: bump OpenCode Go count 12 → 13
- openaiShim.ts: include opus-4-8 / opus-4.8 in the adaptive thinking
detection so the new model uses the adaptive + effort path instead
of falling back to budgetTokens
- effort.ts: modelUsesOpenAIEffort now also rejects models that include
'claude-' or 'gemini-' — without this, OpenCode Claude/Gemini
routes (provider=openai) were misclassified as OpenAI-style and
could leak xhigh past the new gate
- effort.codex.test.ts: lock in the new exclusion with a regression
test against the openai provider
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(effort): address reviewer feedback on xhigh effort + new models
Closes the three P2 findings from PR #1505 review:
1. Settings schema now accepts 'xhigh' so a persisted xhigh survives
restart instead of being silently dropped by .catch(undefined).
2. ModelPicker /effort cycle is driven by getAvailableEffortLevels(model)
instead of a boolean includeMax, so models supporting xhigh
(opus-4-7/4-8, OpenAI/Codex) can actually select it from the picker.
displayEffort clamp now uses the available levels list, so stale
xhigh also clamps to high when the focused model doesn't support it.
3. SDK/control metadata uses getAvailableEffortLevels(model) instead of
the EFFORT_LEVELS fallback that advertised xhigh to every max-capable
model. SDK schema + generated types extended to include 'xhigh'.
Also fixes a latent generator bug: the array case in generate-sdk-types
now parenthesizes union/intersection elements so the trailing [] binds
the whole type, e.g. ("a"|"b")[] rather than "a"|"b[]. Without this,
the regenerated xhigh levels ended up typed as the single-literal
"xhigh"[] and broke the modelInfo assignability check.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(effort): order xhigh before max in EFFORT_LEVELS
EFFORT_LEVELS now matches getAvailableEffortLevels() output order
(['low', 'medium', 'high', 'xhigh', 'max']), and the order asserted by
the existing effort.codex.test.ts tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(effort): order xhigh before max in settings + SDK schemas
Matches the EFFORT_LEVELS / getAvailableEffortLevels order from the
previous commit. The Zod enum order doesn't affect runtime validation,
but keeps the source consistent and avoids confusion if anyone reads
the enum literal to infer display order.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(effort): clamp ModelPicker selection and mark xhigh as current
- ModelPicker.handleSelect: clamp the emitted/persisted effort to the
focused model's available levels so a toggled-but-unsupported level
(e.g. 'xhigh' on a model that doesn't support it) is never written
to settings.json or handed to the consumer. Add focusedAvailableLevels
+ focusedDefaultEffort to the memo guard so the function regenerates
when the focused model changes.
- EffortPicker: compare the xhigh option against the persisted 'xhigh'
level directly. The 'max' alias path is kept only for legacy
settings.json values that still hold 'max' from before xhigh was
introduced.
* docs(effort): fix stale EffortPicker comment about xhigh normalization
openAIEffortToStandard is a type cast that passes 'xhigh' through as a
first-class EffortLevel — the shim only converts to 'max' at the
Anthropic request boundary, not here. Update the comment to match.
* docs(effort): update /effort help to match xhigh support matrix
The /effort --help output still described max as "Opus 4.6 only" and
xhigh as an "alias for max", but this PR promotes xhigh to a first-class
EffortLevel and allows it for OpenCode Claude Opus 4.7/4.8 (with max
also allowed for those Opus variants). Update the help so it matches
the picker/runtime behavior:
- max: "(Opus 4.6+)"
- xhigh: "(OpenAI/Codex and Opus 4.7+)"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(sdk): address reviewer P2 — sync xhigh across override union, schemas, CLI
- Add 'xhigh_effort' to ModelCapabilityOverride union so the new
call at effort.ts:93 typechecks (P2 finding 1).
- Add 'xhigh' to AgentDefinition.effort enum (coreSchemas.ts) and
control.applied.effort enum (controlSchemas.ts), then regenerate
coreTypes.generated.ts so the SDK public contract matches the
first-class effort level (P2 finding 2).
- Add 'xhigh' to the --effort CLI flag allowed list and help text
(main.tsx:945-951) so users can actually pass --effort xhigh
instead of hitting "It must be one of: low, medium, high, max".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(effort): narrow allowlist to shim-serialized models; sync max description
Address reviewer findings on PR #1505:
P2: The broad `m.includes('opus-4') || m.includes('sonnet-4')` branch
made older variants (claude-opus-4-1, claude-sonnet-4-5) advertise
effort support, but the Anthropic /messages shim only serializes
low/medium as anthropicBody.effort for the isAdaptive || isOpus45
set (opus-4-5/4-6/4-7/4-8, sonnet-4-6). For other models the shim
only emits thinking for high/max, so low/medium on those models
was silently dropped on the wire. Collapse the two 4-model branches
into one that matches the shim's serialization set; the substring
match still covers prefix variations (claude-, opencode-claude-).
P3: getEffortLevelDescription('max') said "Opus 4.6 only" but
modelSupportsMaxEffort now allows opus-4-6, opus-4-7, opus-4-8.
Update the shared description to "Opus 4.6+" so the picker and
/effort confirmation agree with the new support matrix (matching
the /effort --help text from 3cf5de2).
Add effort.codex.test.ts coverage: assert that opus-4-5/4-6/4-7/4-8
and sonnet-4-6 support effort, while opus-4-1, opus-4-2, and
sonnet-4-5 do not (the latter three were previously true via the
broad substring match and are now correctly excluded).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: trigger CodeRabbit re-review
* fix(effort): gate modelSupportsXHighEffort on modelSupportsEffort
---------
Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(typecheck): type Doctor screen state
* test(typecheck): isolate Doctor module mocks
* test(typecheck): avoid shared lock in Doctor test
* test(typecheck): trim Doctor keybinding test harness
* test(typecheck): inject Doctor diagnostics dependencies
* test(typecheck): isolate Doctor diagnostics test dependencies
* test(typecheck): cover Doctor dist tags without Ink render
* fix(typecheck): handle Doctor diagnostic load failures
When useState(null) is used for state that later accepts string,
boolean, or number values, TypeScript infers the state type as
null, making the setter reject non-null values (TS2345, TS2322).
This adds explicit type parameters to 11 files in the simplest
cases: string, boolean, number, and known string unions.
components/mcp/MCPReconnect.tsx string | null
commands/btw/btw.tsx string | null (error + response)
commands/tag/tag.tsx string | null (sessionId)
commands/rate-limit-options/ ReactNode | null
commands/thinkback/thinkback.tsx string | null, boolean | null
components/TeleportError.tsx TeleportLocalErrorType | null
components/TeleportRepoMismatchDialog string | null
components/DesktopHandoff.tsx string | null
components/ThinkingToggle.tsx boolean | null
components/AutoUpdaterWrapper.tsx boolean | null (2 states)
components/PromptInputFooterLeftSide number | null
Complex object types (GroveConfig, EnvironmentResource, etc.) and
deeply multi-state components (LogSelector, PluginSettings, Doctor)
are deferred to follow-up PRs.
Refs: #1486
* fix(typecheck): replace 'external' === 'ant' dead-code literals with isAntEmployee()
The build system replaces process.env.USER_TYPE with the string literal
'external' at build time. Dead-code elimination then removes branches
where 'external' === 'ant'. But TypeScript sees these as impossible
comparisons (TS2367) because the narrowed literal type 'external'
never equals 'ant', producing 90 type errors across 27 files.
Replace all 'external === 'ant'' with isAntEmployee() and
'external !== 'ant'' with !isAntEmployee(). The function already
exists in src/utils/buildConfig.ts and always returns false, so this
is a behavioral no-op that makes the intent explicit and type-safe.
The process.env.USER_TYPE === 'ant' pattern in other files is not
touched; it will be addressed in a follow-up.
Refs: #1486
* fix(build): replace isAntEmployee() calls with false at build time for DCE
The bundler cannot dead-code-eliminate branches guarded by isAntEmployee()
because it's an opaque function call. Extend the feature-flag preprocess
plugin to also replace isAntEmployee() with false during bundling, so
dynamic import() and require() calls gated behind ant-employee checks
are eliminated from the external build.
Also export IS_ANT_EMPLOYEE as a named constant for call-site readability
and documentation, with the function kept as a convenience wrapper.
* fix(build): use IS_ANT_EMPLOYEE constant for ant-only import/require guards
CodeRabbit review identified that isAntEmployee() is a runtime function call
that bundlers cannot evaluate for DCE. Replace all isAntEmployee() guards on
dynamic import()/require() calls of ant-internal modules with the
IS_ANT_EMPLOYEE boolean constant (exported as `false as const`), which the
build-time source transform can replace with a literal `false` for DCE.
Also extend the featureFlagPreprocessPlugin to replace IS_ANT_EMPLOYEE with
false during bundling, and clean up the resulting dead imports/exports
(`import { false, isAntEmployee }` → `import { isAntEmployee }`,
`export const false = false as const` → removed).
Affected ant-only modules (all missing from OpenClaude, must be DCE'd):
- sessionDataUploader.js, eventLoopStallDetector.js, sdkHeapDumpMonitor.js
- ccshareResume.js, cli/up.js, cli/rollback.js, cli/handlers/ant.js
- useFrustrationDetection.js, useAntOrgWarningNotification.js
- AntModelSwitchCallout.js, UndercoverAutoCallout.js
* fix(typecheck): add missing type aliases to message and tools stubs
The type stubs in types/message.ts and types/tools.ts were missing
26 and 9 exported type aliases respectively that consumers import.
All are added as X is /usr/bin/X
= not found
any not found to match the existing stub pattern.
This eliminates ~83 TS2305 errors across the codebase where consumers
tried to import types that didn't exist in the stubs.
Refs: #1486
* fix(typecheck): preserve generic message stubs
* feat(goal): add persisted session goal state
Introduce the session-scoped goal state model, bounded evaluator, continuation controller, and transcript metadata persistence. Restore active goals on session resume while keeping achieved and cleared goals from auto-running.
* feat(goal): add slash command controls
Register /goal as a lazy local command with set, status, pause, resume, clear, and clear aliases. Route command-started continuations through hidden meta messages and make /clear clear active goal state.
* feat(goal): continue goals through stop hooks
Evaluate active goals once per terminal assistant turn after configured Stop hooks pass. Incomplete goals reuse the blocking-error continuation path; complete goals persist achieved status without spawning a parallel queue.
* test(goal): cover commands continuation and resume
Add focused coverage for command validation and aliases, state transitions, evaluator malformed-output handling, Stop-hook precedence, SDK/headless visibility, /clear lifecycle behavior, and durable resume persistence.
* fix(goal): fail closed on evaluator failures
* fix(goal): clear stale goal on resume
* Persist goal continuations before auto-resume
* test: isolate CI-sensitive state
* test: isolate attribution provider state
* test: tighten CI state isolation
* fix(goal): clear cached metadata on resume
* test(goal): harden resume metadata coverage
* test: clean up teammate model fixture merge
* fix(goal): align persistence session id type
* fix(goal): address status command review
* fix(goal): address follow-up review comments
* test(goal): isolate review regression coverage
* test(goal): make queryengine fixture ci-safe
* fix(goal): clarify resume message
* test: restore api preconnect provider mock
* fix(goal): address review feedback
The LegacyAPIProvider union type includes 'xai' but the three
DEPRECATED_MODELS entries were missing the corresponding key,
causing TS2741 'Property xai is missing' errors.
Refs: #1486
* Enhance OpenClaude VS Code extension with Microsoft Foundry / Azure OpenAI support. Added configuration options for Azure API key, endpoint, and deployment settings. Updated README and documentation for new features, including a setup wizard for Azure integration. Improved terminal launch environment handling for Azure compatibility.
* Fix packaged Windows helper runtime references
* Use installed CLI from Windows helper aliases
* Scope Windows helper env overrides to invocation
* Align Windows alias docs with shipped helper
* fix(mcp): pass MCP stdio server args as separate array elements to prevent shell injection (issue #131)
* fix: extract buildMcpStdioCommand helper and add regression tests (PR #131 review)
* fix(mcp): handle shell -c prefix in buildMcpStdioCommand (PR #131 review)
When CLAUDE_CODE_SHELL_PREFIX contains -c (e.g. sh -c, bash -c), the
original MCP command and args must be joined as a single shell command
string after -c. Without this join, sh -c runs only the first word as
the command string and treats remaining entries as positional parameters,
so the MCP server never receives its configured arguments.
- Detect -c in prefixParts and join command+args into one string
- Non-shell prefixes (docker run --rm -i, bunx, etc.) unchanged
- Add regression test for sh -c pattern
* chore: add .tmp to gitignore
* fix(mcp): shell-quote each arg in sh -c join to prevent injection (PR #131 P2 fixup)
* fix(mcp): preserve spaced executable path in buildMcpStdioCommand -c split (PR #131 fixup)
Use lastIndexOf(' -c') instead of whitespace split so paths like 'C:\Program Files\Git\bin\bash.exe -c' are handled correctly. Removes dead old code left in from previous edit.
* Harden release publish checks and remove vulnerable Firecrawl SDK
Add a post-publish npm verification step to the release workflow so GitHub releases fail if the npm latest tag does not resolve to the expected version within the retry window.
Update dependency pins to remediate the audit findings by moving axios to 1.16.0, upgrading the Anthropic SDK to 0.94.0, and bumping the Bedrock and Vertex wrapper packages so Bun installs dedupe onto the patched SDK.
Replace the @mendable/firecrawl-js dependency with a small in-repo fetch-based Firecrawl client used by WebFetchTool and the Firecrawl web-search provider. Preserve self-hosted support, add transient 502 retry/backoff behavior, and cover the new client with focused tests.
Validation:
- bun test src/tools/firecrawl/client.test.ts src/tools/WebSearchTool/providers/firecrawl.test.ts
- bun run build
- bun run smoke
- packed-install npm audit --omit dev --json returned 0 vulnerabilities
* Harden Bun test isolation for release validation
Fix shared-module test leaks that were breaking providerProfile in the full serialized Bun suite.
- preserve full module surfaces when mocking env/provider modules
- remove unnecessary env/envUtils mocks from user/install surface tests
- use a fresh providerProfile module import for the Codex OAuth cleanup regression
- relax the Windows-only permission assertion in providerProfile tests
Validation:
- bun install --frozen-lockfile
- bun test --max-concurrency=1
- bun run smoke
- bun run build
- npm pack
* Complete execa mock coverage in user test
Fix the remaining cross-file Bun mock leak reported in review by expanding the persisted execa mock in src/utils/user.test.ts to include execaSync.
This keeps later imports that touch secure-storage and exec helpers from failing or hanging when bun test runs files serially after user.test.ts.
Validation:
- bun test src/utils/user.test.ts src/utils/effort.codex.test.ts
- bun test --max-concurrency=1
- bun run build
- bun run smoke
- npm pack
* Preserve full module surfaces in user test mocks
Convert the auth, config, cwd, and execa mocks in src/utils/user.test.ts into pass-through mocks with targeted overrides.
This fixes the remaining Bun process-global mock leakage where later suites could fail or hang after user.test.ts because leaked partial mocks were missing exports such as auth/config helpers or execaSync.
Validation:
- bun test src/utils/user.test.ts src/utils/effort.codex.test.ts
- bun test src/utils/user.test.ts src/utils/openclaudeInstallSurfaces.test.ts
- bun test --max-concurrency=1
* Override ip-address to 10.2.0
Add a top-level override for ip-address and refresh bun.lock so the MCP SDK -> express-rate-limit path resolves to ip-address@10.2.0 instead of 10.1.0.
This keeps the branch's audit-remediation scope aligned with the remaining transitive advisory path without changing the direct MCP SDK pin.
Validation:
- bun pm why ip-address
- bun audit
* fix: use cleanup-safe Firecrawl timeouts
* Isolate attribution settings tests
* test: remove stale provider profile import
---------
Co-authored-by: JATMN <12479882+jatmn@users.noreply.github.com>
* fix(api): retry once with provider-capped max_tokens
Parse provider-returned output token caps from runtime errors, classify them for recovery, and retry the query once with the lower cap.
Cover malformed caps, non-lowering overrides, and reduced-cap retry failures so the retry path does not loop or accept unsafe provider values.
* fix(api): persist provider max token caps across follow-ups
* test: isolate CI-sensitive state
* test: isolate attribution provider state
* test: isolate xai callback server tests
* test: tighten CI state isolation
* test: clean up teammate model fixture merge
* fix(api): narrow provider max token retry scope
* test: stabilize attribution settings mocks
* fix(typecheck): restore control protocol type exports
* fix(sdk): align control initialize contract
* fix(sdk): expose control initialize response types
* fix: add error context to silent catches and debugger detection
main.tsx:
- Add logError to silent migrateChangelogFromConfig catch -- migration
failures are now surfaced (with the original error as cause) instead
of being silently dropped. Will still retry on next startup.
- Add stderr message before process.exit(1) in debugger detection --
users see why the process exited.
- Add logForDebugging to silent catch block in logManagedSettings --
errors are no longer swallowed.
- Add .catch(logError) to 3 void getSystemContext() calls -- git
failures are now logged.
src/utils/worktree.ts:
- Convert two diagnostic console.log sites ("Using worktree via hook"
and "Created worktree") to logForDebugging. These were diagnostic
output, not user-facing; sending them through the debug log pipeline
removes the need for the biome-ignore comments and stops polluting
stdout in normal use. The iTerm2 tip console.log further down is
intentionally user-facing guidance and is left untouched.
src/utils/fileHistory.ts:
- Remove dead ENABLE_DUMP_STATE flag and maybeDumpStateForDebug helper
(flag was hard-coded to false, so the helper never ran). Also remove
the two call sites in fileHistoryTrackEdit and fileHistoryMakeSnapshot,
and drop the now-unused `inspect` import from 'util'.
* fix: include error context in logManagedSettings debug log
Capture the error in the logManagedSettings catch and include its formatted message in the debug log so failures while reading policy settings, formatting managed keys, or sending the analytics event surface exception details instead of a generic message.
* fix: replace codex input_text with standard text type in generic responses API fallback
* chore: include missing openaiShim refactor files
* fix: address PR feedback (pass isCodex for plain strings, remove duplicate split files)
* fix: use responses_compat mode to safely override to text parts
* fix: address reviewer feedback for responses_compat
* Address review comments for responses_compat fallback schema and status message
* Thread responses_compat through provider profile env helpers
* Fix typescript issues reported by CI
* Update visibleOptionCount to 3 for API format selector
* Fix Responses converter typing and remove trailing whitespace
* feat: thread --fallback-model into REPL interactive sessions (#1346)
* doc: remove outdated --print-only note from --fallback-model help text
* fix: thread fallbackModel through ResumeConversation to REPL
* fix: thread fallbackModel through background session path
* test: add regression coverage for fallbackModel prop plumbing
* test: rewrite fallback test to read sources as text (no transitive imports)
* test: assert specific query paths for fallbackModel plumbing
* test: assert specific call sites for fallbackModel plumbing
The previous regression test counted fallbackModel tokens in REPL.tsx
and used a loose 8KB window after startBackgroundSession(. Either the
foreground query({ ... }) call or the background queryParams = { ... }
entry could be removed and the test would still pass from the prop
type, destructuring, dep array, and the other path's token.
Replace the loose assertions with bounded regex/snippet checks
anchored to each specific call site:
- REPL function signature: function REPL({ ... fallbackModel ... }: Props)
- foreground: a complete query({ ... fallbackModel ... }) call
- resume: the <REPL ... /> element contains fallbackModel={fallbackModel}
- background: queryParams block contains fallbackModel, and
startBackgroundSession(...) references queryParams
- main.tsx: launchResumeChooser(...) uses ...sessionConfig spread,
and the sessionConfig is built with fallbackModel: userSpecifiedFallbackModel
The main.tsx call is multi-line with nested objects and other arg
lists (e.g. getWorktreePaths(getOriginalCwd())); a balanced-paren
scan finds the matching close so the spread assertion sees the full
call body.
Addresses the jatmn review thread on #1346.
* fix(1346): forward --fallback-model through SSH; tighten background test
- SSH interactive pre-extract now mirrors --model handling for
--fallback-model so the flag survives the remote spawn via
_pendingSSH.extraCliArgs (handles both space and =value forms).
- Background test no longer relies on a loose /queryParams[\\s\\S]*?fallbackModel/
regex: replaced with a balanced-brace walker that bounds the
assertion to the body of the queryParams object literal in
REPL.tsx, so a regression that drops fallbackModel from the
queryParams block can no longer be masked by a later reference
(e.g. useEffect dep array).
Addresses round-4 review feedback on #1419.
* test(1346): use paren-walking for startBackgroundSession call slice
The startBackgroundSession(...) call in REPL.tsx contains nested
function calls (getQuerySourceForREPL, getAutoCompactTrackingForSession,
setAutoCompactTrackingForSession), so source.indexOf(')') landed on
the first inner close-paren instead of the call's matching close.
The test only passed because queryParams happened to appear in the
outer object before those nested calls.
Replaced with the same paren-walking depth-tracking pattern used by
the launchResumeChooser test in this file. Bounds the slice to the
actual startBackgroundSession call body, so a regression that moves
queryParams out of the outer argument list (e.g. into a nested
helper) is now caught by the assertion.
Addresses CodeRabbit nitpick on #1419.
---------
Co-authored-by: adityachaudhary99 <adityachaudhary99@users.noreply.github.com>
* feat(snip): implement HISTORY_SNIP — model-callable snip tool for context management
- snipProjection.ts: boundary detection + view filter (isSnipBoundaryMessage, projectSnippedView)
- snipCompact.ts: pending registry, snipCompactIfNeeded, shouldNudgeForSnips, SNIP_NUDGE_TEXT
- SnipTool/: model-callable snip tool with Zod schema (prompt.ts + SnipTool.ts)
- types/message.ts: add SystemCompactBoundaryMessage export
- scripts/build.ts: enable HISTORY_SNIP: true
- QueryEngine.ts: fix snipReplay return type
* docs: add MCP_SKILLS implementation plan
* docs: add HISTORY_SNIP implementation plan
* fix(snip): prune headless store on snip-boundary replay
The snipReplay path called snipCompactIfNeeded with a {force:true} option
that the function never read, and the pending-snip set was already cleared
when the boundary was produced in query.ts — so the replay always reported
nothing removed and mutableMessages never shrank in long SDK sessions.
Prune the store by the boundary's own removedUuids via projectSnippedView
instead. Also drop two planning docs that were committed to the branch.
* fix(snip): persist snip boundary in SDK/headless transcripts
When a snip boundary was yielded in the SDK/headless path, snipReplay pruned
the in-memory mutableMessages store but the branch broke before adding the
boundary to the local messages array or calling recordTranscript. Later
transcript writes used the pre-snip messages copy, so the on-disk transcript
kept the removed messages and no snipMetadata boundary. After a restart or
--resume, loadTranscriptFile reconstructed the un-snipped history and the
context reduction was lost.
Mirror the boundary into the local messages copy and record it when the snip
executes, matching the compact_boundary path. recordTranscript is append-only
by UUID, so the pre-snip messages already on disk remain and the appended
boundary (carrying snipMetadata.removedUuids) lets applySnipRemovals prune
them on load.
Add a loadTranscriptFile round-trip test covering the previously-untested
snip replay: a persisted boundary prunes its removedUuids and relinks
survivors whose parentUuid pointed into the removed gap.
* fix(history-snip): record paired tool-result removals and scope pending snips per conversation
Two issues in the snip path:
1. Persist every removed message. snipCompactIfNeeded drops the paired
tool-result user messages of a snipped assistant tool-use message from the
live context, but the boundary only recorded the explicitly-marked UUIDs.
projectSnippedView / loadTranscriptFile replay solely from
snipMetadata.removedUuids, so on --resume the tool results came back orphaned
(their assistant message stayed removed) and part of the reduction was lost.
Record the paired tool-result UUIDs in removedUuids so replay drops the same
set the live snip dropped.
2. Scope pending snips per conversation. The pending registry was module-global
and stored model-facing short IDs, then cleared unconditionally on every
snipCompactIfNeeded pass. With concurrent in-process sessions, session B could
clear A's pending IDs (losing A's snip) or, on a short-ID collision, prune the
wrong message. Resolve short IDs to full UUIDs at mark time against the
snipping conversation's own messages, and consume only the UUIDs present in
the current message array. UUIDs are globally unique, so the registry
self-scopes: one session can no longer consume or mis-target another's.
* fix(history-snip): drop paired assistant tool-use when snipping a tool-result message
[id:] tags are appended to user messages only, so the model snips a
tool-result user message, not the assistant tool_use. The previous
pairing only ran assistant->user; snipping a tool-result left the
preceding assistant tool_use orphaned, so the next API-prep pass
synthesized a placeholder result and the tool interaction was never
actually removed from live context or from replay.
Pair in both directions: when a snipped user message's tool_results all
belong to an assistant turn, drop that assistant tool_use too (mirroring
the existing .every() guard so partially-snipped turns are kept), and
record its UUID in the boundary's removedUuids so replay drops the same
set.
* fix(history-snip): add SnipBoundaryMessage render component
HISTORY_SNIP ships enabled, so Message.tsx reaches the snip_boundary
render branch after the first snip. That branch requires
./messages/SnipBoundaryMessage.js and renders its named
SnipBoundaryMessage export, but no source file existed — the build
emitted a missing-module-stub exporting only a default noop, so the
named component was undefined and the render crashed right after a
successful snip.
Add the component, mirroring CompactBoundaryMessage: a single dimmed
line marking the snip with the removed-message count and the transcript
shortcut. The build now resolves the import (no stub) and the named
export is present in the bundle.
* build: guard against enabled-feature imports resolving to missing-module stubs
The missing-import scanner stubs any unresolved relative import to a noop
default export. For a require behind a DISABLED feature flag that is correct
(dead-code-eliminated, never bundled). But when a flag is ENABLED the gated
require becomes live and the stub silently degrades a real module to
() => null, so a named export resolves to undefined and crashes the first
time that path runs. SnipBoundaryMessage shipped exactly this way: build,
smoke, and unit tests all passed while the UI crashed on the first snip.
Feature-flag DCE removes disabled branches before bundling, so every
missing-module-stub marker left in dist/cli.mjs is reachable in the shipped
build. After the CLI bundle, fail the build on any stub marker not explicitly
grandfathered in ACCEPTABLE_RUNTIME_STUBS (seeded with the pre-existing
stubs), and warn on stale allowlist entries. Verified: removing the
SnipBoundaryMessage source makes the guard fail and name the module.
* fix(history-snip): drop unmirrored force-snip command registration
force-snip is gated on HISTORY_SNIP but its source (./commands/force-snip.js)
was never mirrored into this build, so require(...).default resolved to the
missing-module stub's noop. That truthy noop was spread into the command list
(commands.ts:252), registering a bare () => null as a command with no name,
description, or call — broken the moment anything enumerates commands. Enabling
HISTORY_SNIP turned this live, same class as the SnipBoundaryMessage crash.
Remove the registration rather than ship a phantom command: the implementation
is not present in this tree, so the honest behavior is to not register it. Drop
the matching ACCEPTABLE_RUNTIME_STUBS entry so the bundle guard stays strict.
* fix(history-snip): don't snip a tool result that would orphan a surviving tool_use
The result-side pairing dropped an explicitly-snipped tool-result user
message even when its paired assistant turn had other, un-snipped tool
calls. That left the assistant holding a tool_use with no matching result,
which the next API-prep pass repairs with a synthetic placeholder
(src/utils/messages.ts), so the snip never actually took effect and the
restored context still carried the stale interaction.
Block-level surgery on the surviving assistant is not an option: replay
(projectSnippedView / loadTranscriptFile) drops whole UUIDs, not blocks, so
the live store and a --resume would diverge. Instead, treat an unclean snip
as a no-op: a tool_use is safely removable only if its whole assistant turn
goes with it (the assistant is explicitly snipped, or every tool_use in it
has its result snipped). A tool-result user message whose results don't all
pair to a removable tool_use is kept, and no boundary is emitted when nothing
was cleanly removable, keeping live context and replay identical.
* docs(build): document the bundle-stub guard as a coarse tripwire
The guard rationale claimed every missing-module-stub marker left in
dist/cli.mjs is reachable in the shipped build and that each allowlisted
entry is latent runtime debt behind an enabled flag. That overstates it: the
scanner keys missing modules by specifier string, so a same-named specifier
missing in one importer (including a test file) can leave a marker even when
another importer resolves the real module, and a marker can sit on a path
that never runs. Reword the comment and error message so a flagged stub reads
as "inspect this", not "confirmed runtime crash"; the guard reliably catches
a NEW stub appearing where none was expected, which is its actual value.
* fix(build): canonicalize bundle stub markers before diffing the allowlist
The bundle guard compared raw `missing-module-stub:` marker text against
ACCEPTABLE_RUNTIME_STUBS, but the marker format is not stable across build
hosts: locally Bun emits the relative import specifier
(`./commands/fork/index.js`), while on the Linux CI merge run it emitted the
same grandfathered stubs as absolute source paths
(`/home/runner/work/openclaude/openclaude/src/commands/fork/index.ts`). The raw
diff therefore failed `bun run smoke` on CI for already-allowlisted stubs and
also reported them as stale.
Canonicalize both the bundle markers and the allowlist to a stable key (the
basename without extension) before diffing, so a stub matches in either form.
Basename is the only reduction that unifies a relative specifier of unknown
depth with an absolute path (a fixed path-segment count breaks single-segment
specifiers like `./dream.js`). The allowlist keeps the readable full specifiers;
diagnostics still print the raw marker. Guard against two allowlist entries
sharing a basename (which would let one silently cover an unrelated stub) by
failing the build if the canonical set is smaller than the allowlist.
* chore(build): drop allowlist stubs resolved by current main
Rebasing onto current main brings in the per-importer scanner (#1399)
and the real sources for four previously-stubbed modules, so they no
longer emit missing-module markers:
- ../../utils/hooks/ssrfGuard.js (per-importer keying, #1399/#1450)
- ./dream.js (/dream restored, #1399)
- ./UserForkBoilerplateMessage.js (source mirrored, #1451)
- ./commands/fork/index.js (unmirrored /fork dropped, #1451)
The bundle guard flagged all four as stale allowlist entries. Remove
them and refresh the guard rationale comment, which described the
pre-#1399 specifier-string scanner; the scanner now keys per importer.
* fix(history-snip): expose snip id on pure tool-result messages
appendMessageTagToUserMessage() only appended the [id:...] tag to a
string body or an existing text block. A user message that is purely
tool_result blocks (the normal shape for large Read/Bash outputs) has
no text block, so it returned unchanged and carried no visible id. Those
are exactly the highest-value snip targets the feature prompts the model
to remove, yet the model had no id to reference them by.
Append a dedicated text block holding the tag when a tool-result-only
message has no text block. The tool_result block is left intact, so snip
pairing is unaffected, and the tag lands on the API-bound copy only.
Export the function and add colocated tests covering string body, text
block, the pure tool_result case, and meta passthrough.
* fix(build): key bundle-stub guard on repo-relative path, not basename
The guard canonicalized every missing-module-stub marker to its basename
before checking the allowlist, so a future stub named constants.ts (or
cachedMCConfig.ts, MonitorMcpDetailDialog.ts) from any other directory
would be treated as allowlisted and slip past the guard — the exact
regression class the guard exists to catch.
Post-#1399 the per-importer scanner records each stub as the resolved
absolute source path, which differs across build hosts only by the
repo-root prefix. So key on the repo-relative path from src/ onward
(without extension): stable across hosts yet path-specific, so a stub
cannot mask a same-named file elsewhere. Drop the now-moot basename
collision guard and store the allowlist as repo-relative keys.
* fix(history-snip): describe snip as a queued, refusable request
SnipTool's tool result said "Marked N message(s) for removal. They will
be removed from context before the next model call" based only on the
count of input IDs. But snipCompactIfNeeded() can refuse the exact
request on the next turn: it keeps a tool_result whose paired tool_use
would survive (snipping it would orphan the tool call), freeing 0 tokens
and emitting no boundary. The model was told the output would be removed,
then saw it still in context with no failure signal, so it treated a
structural no-op as a successful context reduction.
Reword the tool result to describe the snip as a queued request that may
be refused, name the one refusal condition (would orphan a paired tool
call, e.g. one result from a parallel-tool turn), and give the model the
observable signal and repair: a kept message re-shows its [id:...] tag
next turn (tags are re-applied every API-prep pass), and snipping all of
that turn's tool results together removes them cleanly.
Add SnipTool.test.ts pinning the queued/refusable wording.
* test(history-snip): import UserMessage from its canonical module
messages.snipTag.test.ts imported UserMessage from ../query.js, which
imports the type but does not re-export it (TS2459). Import it from
../types/message.js, the canonical source messages.ts itself uses, so
the snip test files typecheck cleanly.
* fix(history-snip): make snip id tag injection idempotent
appendMessageTagToUserMessage() documents that it only mutates the
API-bound copy, but query.ts builds the next loop state's toolResults
from normalizeMessagesForAPI([update.message]) (query.ts:1589) and stores
that normalized, already-tagged output into state.messages
(query.ts:1976). With HISTORY_SNIP enabled the tag is carried forward as
conversation state, so the next turn re-normalizes it and appends the
same [id:...] a second time. In multi-tool agent loops every prior tool
result accumulates another duplicate tag each iteration, bloating context
and showing the model repeated IDs that are meant to be an API-projection
affordance only.
Guard the append: if the message already carries its own [id:<id>] token
(string body, last text block, or the dedicated tool_result text block),
return it unchanged. The token is derived from the message's own uuid, so
its presence means it was already tagged. Adds 3 idempotency tests.
* fix(history-snip): expose every parallel-tool sibling id before merge
normalizeMessagesForAPI tagged snip [id:] markers only after merging
consecutive user messages. A parallel-tool assistant turn yields several
adjacent tool_result user messages; the merge keeps just the first
operand's uuid, so on the resume/reload path (where the persisted
transcript is the untagged original) only the first sibling's id reached
the model. snipCompactIfNeeded refuses to drop one result of such a turn
(it would orphan the surviving tool_use), so the model needed every
sibling's id to request the whole-turn removal the snip prompt instructs,
and could never form it: a permanent no-op.
Inject the tag per user message before the merge instead, so each
sibling carries its own [id:] and joinTextAtSeam preserves them all,
matching the live path where each result is tagged at push time. The
post-merge sweep stays (idempotent) to tag user messages synthesized
during normalization (local_command, attachments).
Test: merging tagged parallel siblings keeps every sibling id and both
tool_result blocks.
* test(history-snip): type snip-replay test ids as UUID
loadTranscriptFile() returns Map<UUID, TranscriptMessage>, but the test
id() helper returned plain string, so every messages.has/get/
buildConversationChain call in the persisted-snip replay test raised a
TS2345 against the UUID-keyed map. Type id() as UUID (casting the literal
once at the source) so the new replay coverage does not add touched-path
typecheck debt. Also clears the same error cluster in the pre-existing
compact-boundary tests that share the helper.
* docs(history-snip): drop removed /force-snip from setMessages comment
The QueryEngine setMessages comment cited /force-snip as its example of a
message-mutating slash command, but that command was removed. Point the
example at /clear (src/commands/clear/conversation.ts), which still mutates
the message array via setMessages, so the comment stays accurate.
* refactor(history-snip): type SnipBoundaryMessage removedUuids as string[]
removedUuids holds message UUID strings throughout the snip feature, but
the SnipBoundaryMessage prop typed it as unknown[]. Narrow it to string[]
so the type carries intent and the test fixture no longer needs an
`as never` cast to satisfy the prop (the cast bypassed type checking and
could have hidden a real fixture/prop mismatch).
* fix(history-snip): drop stale cachedMCConfig stub-allowlist entry
cachedMCConfig.ts now exists in the tree and bundles as real code, so it
is no longer emitted as a missing-module stub. The grandfathered baseline
listed it among acceptable stubs, which made the new guard print a stale
warning and, worse, would silently accept a future reintroduced
cachedMCConfig stub as known debt instead of flagging it. Drop the entry so
the allowlist matches the actual bundle (VerifyPlanExecutionTool/constants
and MonitorMcpDetailDialog).
* fix(history-snip): guard paired snip drops and report queued count
Two CodeRabbit findings on the snip compaction path:
- Mixed-content turns: the inferred paired-drop ran its .every() check over
filtered tool blocks only, so an assistant turn like [text, tool_use] (or a
user [tool_result, text]) was treated as fully droppable and its text was
silently removed when the paired half was snipped. Require the whole message
to be tool blocks before an inferred drop; otherwise treat the snip as a
no-op (the explicit-snip path, where the model deliberately targets a message,
is unchanged and still removes wholesale).
- Queued count: markForSnip only enqueues short IDs it can resolve against the
conversation, but SnipTool reported sniped = input.message_ids.length, which
overstated the result when IDs were stale or unresolvable. markForSnip now
returns the distinct resolved UUIDs and SnipTool reports that length.
* fix(history-snip): align snip prompt with queued-not-guaranteed contract
The tool description told the model snipped IDs are "permanently remove[d]
... before the next model call", but snipCompactIfNeeded queues the request
and keeps a message when removing it would orphan a paired tool_use (the
tool_result already says so). Match the description to that contract so the
model does not treat a structural no-op as a guaranteed removal.
* feat: add .gitattributes to enforce LF line endings
Prevents Windows/NTFS contributors with core.autocrlf=true from
accidentally converting the entire codebase CRLF→LF, which produces
PRs with +600k/-600k diffs across 700+ files.
- * text=auto normalizes all text files to LF on checkin
- Explicit binary markers for images, fonts, archives, compiled code
- No code changes — config-only, zero risk
* fix: treat SVGs as text, not binary
SVGs are XML-based vector files and should be diffable/mergeable.
CodeRabbit review: https://github.com/Gitlawb/openclaude/pull/1550#discussion_r4632644506
* fix: renormalize CRLF TypeScript files to LF
The new *.ts text attribute exposed 3 TS files with CRLF in the index.
Renormalizing so a fresh Windows checkout stays clean. Fixes jatmn's P2 finding.
* Add gemma-4 models to gemini provider
* Add gemma-4 models to gemini UI catalog
* Fix Gemma-4 model IDs and capabilities based on review
* Update Gemma 4 maxOutputTokens based on CI feedback
* feat(opengateway): NVIDIA Nemotron 3 Ultra free model
Add the OpenRouter :free Nemotron 3 Ultra endpoint (550B MoE, 1M
context, 65K output, tools + reasoning) to the opengateway catalog.
Bills $0 and bypasses the gateway credit gate, so it works for every
user — no credits or premium plan needed.
Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
* feat(model): surface catalog notes as a picker tag; tag Nemotron free
Wire ModelCatalogEntry.notes into the route catalog description so the
Nemotron 3 Ultra entry shows "Free · Provider: Gitlawb Opengateway" in
the model picker.
Co-Authored-By: OpenClaude <openclaude@gitlawb.com>
---------
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
Xiaomi deprecated mimo-v2-pro and mimo-v2-omni upstream — requests now
404 with a migrate-to-v2.5 message. Remove them from the opengateway
catalog, the Xiaomi MiMo vendor catalog, model descriptors, brand ids,
and the legacy model picker list. mimo-v2-flash remains (still served).
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
The model left preview — OpenRouter and the gateway now serve
google/gemini-3.1-flash-lite as the canonical id. Swap the opengateway
catalog entry, model descriptors, and brand ids to the GA id and drop
the -preview variants; pricing is unchanged ($0.25/1M in, $1.50/1M out).
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
Add Atlas Cloud (atlascloud.ai) to the README sponsors table with its
banner asset, and add an Atlas Cloud sponsored tip to the tip catalog.
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
Two new models reachable through the Gitlawb Opengateway unified route,
both served upstream via OpenRouter (gateway-side routing lands separately
in the opengateway repo):
- MiniMax M3 (`minimax/minimax-m3`): reuses the existing minimax-m3
descriptor — 1M context, 131k output, reasoning/coding.
- Qwen 3.7 Max (`qwen/qwen3.7-max`): new descriptor — 1M context, 65k
output, text-only per the OpenRouter catalog (no vision), so it skips
the qwenModel helper's vision defaults.
The /model picker test's exact-list assertion gains both entries.
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
* fix(security): prevent CRLF injection, path injection, and error message leakage
openaiShim.ts:
- Validate OPENAI_AUTH_HEADER_VALUE for CRLF characters (\\r, \\n) — prevents HTTP header injection
- URL-encode Azure deployment name in URL path — prevents path traversal via OPENAI_MODEL
- Redact raw response body from error messages — only include content-type, not body content
client.ts:
- Strip CRLF from ANTHROPIC_CUSTOM_HEADERS values — prevents header injection via env var
errors.ts:
- Use generic error message for auth failures — prevents raw error.message leaking internal details
* test: cover crlf rejection in custom headers