fix: normalize malformed Bash tool arguments from OpenAI-compatible providers (#385)

* fix: normalize malformed Bash tool arguments from OpenAI-compatible providers

* fix: keep invalid Bash tool args from becoming commands

* fix: preserve malformed Bash JSON literals

* test: stabilize rebased PR 385 checks

* test: isolate provider profile env assertions

* fix: extend tool argument normalization to all tools and harden edge cases

- Extend STRING_ARGUMENT_TOOL_FIELDS to normalize Read, Write, Edit,
  Glob, and Grep plain-string arguments (fixes "Invalid tool parameters"
  errors reported by VennDev)
- Normalize streaming Bash args regardless of finish_reason, not only
  when finish_reason is 'tool_calls'
- Broaden isLikelyStructuredObjectLiteral to catch malformed object-shaped
  strings like {command:"pwd"} and {'command':'pwd'} (fixes CR2 from
  Vasanthdev2004)
- Apply blank/object-literal guard to all tools, not just Bash
- Extract duplicated JSON repair suffix combinations into shared constant
- Add 32 isolated unit tests for toolArgumentNormalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip streaming normalization on finish_reason length

Truncated tool calls (finish_reason: 'length') now preserve the raw
buffer instead of normalizing into executable commands, preventing
incomplete commands from becoming runnable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: comprehensive tool argument normalization hardening

- Remove all { raw: ... } returns that caused InputValidationError with
  z.strictObject schemas — return {} instead for clean Zod errors
- Extend normalizeAtStop buffering to all mapped tools (Read, Write,
  Edit, Glob, Grep) so streaming paths also get normalized
- Make repairPossiblyTruncatedObjectJson generic — repair any valid
  JSON object, not just ones with a command field
- Export hasToolFieldMapping for streaming normalizeAtStop decision
- Skip normalization on finish_reason: length to preserve raw truncated
  buffer
- Update all test expectations to match new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
KRATOS
2026-04-06 19:38:45 +05:30
committed by GitHub
parent 1e057025d6
commit b4bd95b477
8 changed files with 1539 additions and 53 deletions

View File

@@ -1,7 +1,17 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js'
import { saveGlobalConfig } from '../config.js'
async function importFreshModelOptionsModule() {
mock.restore()
mock.module('./providers.js', () => ({
getAPIProvider: () => 'github',
}))
const nonce = `${Date.now()}-${Math.random()}`
return import(`./modelOptions.js?ts=${nonce}`)
}
const originalEnv = {
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
@@ -14,6 +24,20 @@ const originalEnv = {
ANTHROPIC_CUSTOM_MODEL_OPTION: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION,
}
beforeEach(() => {
mock.restore()
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_BASE_URL
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION
resetModelStringsForTestingOnly()
})
afterEach(() => {
process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
@@ -34,17 +58,9 @@ afterEach(() => {
providerProfiles: [],
activeProviderProfileId: undefined,
}))
resetModelStringsForTestingOnly()
})
async function importFreshModelOptionsModule() {
mock.restore()
mock.module('./providers.js', () => ({
getAPIProvider: () => 'github',
}))
const nonce = `${Date.now()}-${Math.random()}`
return import(`./modelOptions.js?ts=${nonce}`)
}
test('GitHub provider exposes only default + GitHub model in /model options', async () => {
process.env.CLAUDE_CODE_USE_GITHUB = '1'
delete process.env.CLAUDE_CODE_USE_OPENAI
@@ -58,7 +74,9 @@ test('GitHub provider exposes only default + GitHub model in /model options', as
const { getModelOptions } = await importFreshModelOptionsModule()
const options = getModelOptions(false)
const nonDefault = options.filter(option => option.value !== null)
const nonDefault = options.filter(
(option: { value: unknown }) => option.value !== null,
)
expect(nonDefault.length).toBe(1)
expect(nonDefault[0]?.value).toBe('github:copilot')

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, afterEach } from 'bun:test'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
parseProviderFlag,
applyProviderFlag,
@@ -8,18 +8,26 @@ import {
const originalEnv = { ...process.env }
const RESET_KEYS = [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_GITHUB',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'OPENAI_BASE_URL',
'OPENAI_API_KEY',
'OPENAI_MODEL',
'GEMINI_MODEL',
] as const
beforeEach(() => {
for (const key of RESET_KEYS) {
delete process.env[key]
}
})
afterEach(() => {
for (const key of [
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_GITHUB',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'OPENAI_BASE_URL',
'OPENAI_API_KEY',
'OPENAI_MODEL',
'GEMINI_MODEL',
]) {
for (const key of RESET_KEYS) {
if (originalEnv[key] === undefined) delete process.env[key]
else process.env[key] = originalEnv[key]
}

View File

@@ -2,6 +2,10 @@ import { afterEach, describe, expect, mock, test } from 'bun:test'
import type { ProviderProfile } from './config.js'
async function importFreshProvidersModule() {
return import(`./model/providers.ts?ts=${Date.now()}-${Math.random()}`)
}
const originalEnv = { ...process.env }
const RESTORED_KEYS = [
@@ -96,24 +100,26 @@ function buildProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile
describe('applyProviderProfileToProcessEnv', () => {
test('openai profile clears competing gemini/github flags', async () => {
const { applyProviderProfileToProcessEnv, getAPIProvider } =
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.CLAUDE_CODE_USE_GITHUB = '1'
applyProviderProfileToProcessEnv(buildProfile())
const { getAPIProvider: getFreshAPIProvider } =
await importFreshProvidersModule()
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
'provider_test',
)
expect(getAPIProvider()).toBe('openai')
expect(getFreshAPIProvider()).toBe('openai')
})
test('anthropic profile clears competing gemini/github flags', async () => {
const { applyProviderProfileToProcessEnv, getAPIProvider } =
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.CLAUDE_CODE_USE_GITHUB = '1'
@@ -125,11 +131,13 @@ describe('applyProviderProfileToProcessEnv', () => {
model: 'claude-sonnet-4-6',
}),
)
const { getAPIProvider: getFreshAPIProvider } =
await importFreshProvidersModule()
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_GITHUB).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(getAPIProvider()).toBe('firstParty')
expect(getFreshAPIProvider()).toBe('firstParty')
})
})
@@ -177,7 +185,7 @@ describe('applyActiveProviderProfileFromConfig', () => {
} as any)
expect(applied).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:11434/v1')
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
})
@@ -267,7 +275,7 @@ describe('applyActiveProviderProfileFromConfig', () => {
} as any)
expect(applied?.id).toBe('saved_openai')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
})
@@ -286,10 +294,10 @@ describe('persistActiveProviderProfileModel', () => {
model: 'kimi-k2.5:cloud',
})
saveMockGlobalConfig(current => ({
...current,
providerProfiles: [activeProfile],
activeProviderProfileId: activeProfile.id,
saveMockGlobalConfig(current => ({
...current,
providerProfiles: [activeProfile],
activeProviderProfileId: activeProfile.id,
}))
applyProviderProfileToProcessEnv(activeProfile)
@@ -303,7 +311,7 @@ describe('persistActiveProviderProfileModel', () => {
)
const saved = getProviderProfiles().find(
profile => profile.id === activeProfile.id,
(profile: ProviderProfile) => profile.id === activeProfile.id,
)
expect(saved?.model).toBe('minimax-m2.5:cloud')
})
@@ -333,7 +341,7 @@ describe('persistActiveProviderProfileModel', () => {
expect(process.env.OPENAI_MODEL).toBe('cli-model')
const saved = getProviderProfiles().find(
profile => profile.id === activeProfile.id,
(profile: ProviderProfile) => profile.id === activeProfile.id,
)
expect(saved?.model).toBe('minimax-m2.5:cloud')
})
@@ -414,7 +422,7 @@ describe('deleteProviderProfile', () => {
expect(result.activeProfileId).toBeUndefined()
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:11434/v1')
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
})