Merge remote-tracking branch 'origin' into fix/sdd-design-invalid-tool-params

This commit is contained in:
IronRookieCoder 2026-05-12 21:31:12 +08:00
commit 65e55ac763
20 changed files with 1758 additions and 463 deletions

797
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -170,6 +170,7 @@
"fflate": "^0.8.2",
"figures": "^6.1.0",
"fuse.js": "^7.1.0",
"husky": "^9.1.7",
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
@ -206,7 +207,6 @@
"turndown": "^7.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"undici": "^7.24.6",
"url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
@ -237,4 +237,4 @@
"biome format --write --no-errors-on-unmatched"
]
}
}
}

View File

@ -302,7 +302,7 @@ export function Config({
{
id: 'autoCompactEnabled',
label: 'Auto-compact',
value: globalConfig.autoCompactEnabled,
value: globalConfig.autoCompactEnabled ?? true,
type: 'boolean' as const,
onChange(autoCompactEnabled: boolean) {
saveGlobalConfig(current => ({ ...current, autoCompactEnabled }));

View File

@ -18,6 +18,7 @@ import {
waitForRemoteManagedSettingsToLoad,
} from '../services/remoteManagedSettings/index.js'
import { preconnectAnthropicApi } from '../utils/apiPreconnect.js'
import { validateExtraCACertsEnv } from '../utils/caCerts.js'
import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js'
import { registerCleanup } from '../utils/cleanupRegistry.js'
import {
@ -91,6 +92,7 @@ export const init = memoize(async (): Promise<void> => {
// before any TLS connections. Bun caches the TLS cert store at boot
// via BoringSSL, so this must happen before the first TLS handshake.
applyExtraCACertsFromConfig()
validateExtraCACertsEnv()
logForDiagnosticsNoPII('info', 'init_safe_env_vars_applied', {
duration_ms: Date.now() - envVarsStart,

View File

@ -114,7 +114,11 @@ import {
} from './bootstrap/state.js'
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
import { count } from './utils/array.js'
import { createTrace, endTrace, isLangfuseEnabled } from './services/langfuse/index.js'
import {
createTrace,
endTrace,
isLangfuseEnabled,
} from './services/langfuse/index.js'
import { getAPIProvider } from './utils/model/providers.js'
import { uploadSessionTurn } from './utils/sessionDataUploader.js'
@ -133,7 +137,11 @@ function* yieldMissingToolResultBlocks(
) {
for (const assistantMessage of assistantMessages) {
// Extract all tool use blocks from this assistant message
const toolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
const toolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
@ -242,8 +250,9 @@ export async function* query(
logForDebugging(
`[query] ownsTrace=${ownsTrace} incoming langfuseTrace=${params.toolUseContext.langfuseTrace ? 'present' : 'null/undefined'} isLangfuseEnabled=${isLangfuseEnabled()}`,
)
const langfuseTrace = params.toolUseContext.langfuseTrace
?? (isLangfuseEnabled()
const langfuseTrace =
params.toolUseContext.langfuseTrace ??
(isLangfuseEnabled()
? createTrace({
sessionId: getSessionId(),
model: params.toolUseContext.options.mainLoopModel,
@ -497,20 +506,21 @@ async function* queryLoop(
)
queryCheckpoint('query_autocompact_start')
const { compactionResult, consecutiveFailures } = await deps.autocompact(
messagesForQuery,
toolUseContext,
{
systemPrompt,
userContext,
systemContext,
const { compactionResult, consecutiveFailures, consecutiveCompactions } =
await deps.autocompact(
messagesForQuery,
toolUseContext,
forkContextMessages: messagesForQuery,
},
querySource,
tracking,
snipTokensFreed,
)
{
systemPrompt,
userContext,
systemContext,
toolUseContext,
forkContextMessages: messagesForQuery,
},
querySource,
tracking,
snipTokensFreed,
)
queryCheckpoint('query_autocompact_end')
if (compactionResult) {
@ -564,11 +574,14 @@ async function* queryLoop(
// compact. recompactionInfo (autoCompact.ts:190) already captured the
// old values for turnsSincePreviousCompact/previousCompactTurnId before
// the call, so this reset doesn't lose those.
// Preserve consecutiveCompactions so the circuit breaker can stop
// infinite compaction loops when context stays above threshold.
tracking = {
compacted: true,
turnId: deps.uuid(),
turnCounter: 0,
consecutiveFailures: 0,
consecutiveCompactions,
}
const postCompactMessages = buildPostCompactMessages(compactionResult)
@ -794,7 +807,14 @@ async function* queryLoop(
let yieldMessage: typeof message = message
if (message.type === 'assistant') {
const assistantMsg = message as AssistantMessage
const contentArr = Array.isArray(assistantMsg.message?.content) ? assistantMsg.message.content as unknown as Array<{ type: string; input?: unknown; name?: string; [key: string]: unknown }> : []
const contentArr = Array.isArray(assistantMsg.message?.content)
? (assistantMsg.message.content as unknown as Array<{
type: string
input?: unknown
name?: string
[key: string]: unknown
}>)
: []
let clonedContent: typeof contentArr | undefined
for (let i = 0; i < contentArr.length; i++) {
const block = contentArr[i]!
@ -830,7 +850,10 @@ async function* queryLoop(
if (clonedContent) {
yieldMessage = {
...message,
message: { ...(assistantMsg.message ?? {}), content: clonedContent },
message: {
...(assistantMsg.message ?? {}),
content: clonedContent,
},
} as typeof message
}
}
@ -876,7 +899,11 @@ async function* queryLoop(
const assistantMessage = message as AssistantMessage
assistantMessages.push(assistantMessage)
const msgToolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
const msgToolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
@ -1016,7 +1043,10 @@ async function* queryLoop(
logEvent('tengu_query_error', {
assistantMessages: assistantMessages.length,
toolUses: assistantMessages.flatMap(_ =>
(Array.isArray(_.message?.content) ? _.message.content as Array<{ type: string }> : []).filter(content => content.type === 'tool_use'),
(Array.isArray(_.message?.content)
? (_.message.content as Array<{ type: string }>)
: []
).filter(content => content.type === 'tool_use'),
).length,
queryChainId: queryChainIdForAnalytics,
@ -1419,7 +1449,6 @@ async function* queryLoop(
queryCheckpoint('query_tool_execution_start')
if (streamingToolExecutor) {
logEvent('tengu_streaming_tool_execution_used', {
tool_count: toolUseBlocks.length,
@ -1479,9 +1508,14 @@ async function* queryLoop(
const lastAssistantMessage = assistantMessages.at(-1)
let lastAssistantText: string | undefined
if (lastAssistantMessage) {
const textBlocks = (Array.isArray(lastAssistantMessage.message?.content) ? lastAssistantMessage.message.content as Array<{ type: string; text?: string }> : []).filter(
block => block.type === 'text',
)
const textBlocks = (
Array.isArray(lastAssistantMessage.message?.content)
? (lastAssistantMessage.message.content as Array<{
type: string
text?: string
}>)
: []
).filter(block => block.type === 'text')
if (textBlocks.length > 0) {
const lastTextBlock = textBlocks.at(-1)
if (lastTextBlock && 'text' in lastTextBlock) {
@ -1670,7 +1704,6 @@ async function* queryLoop(
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
// Inject prefetched skill discovery. collectSkillDiscoveryPrefetch emits
// hidden_by_main_turn — true when the prefetch resolved before this point
// (should be >98% at AKI@250ms / Haiku@573ms vs turn durations of 2-30s).

View File

@ -0,0 +1,247 @@
import { mock, describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { logMock } from '../../../../tests/mocks/log.js'
import { debugMock } from '../../../../tests/mocks/debug.js'
// Mock bun:bundle first (feature flags all off)
mock.module('bun:bundle', () => ({ feature: () => false }))
// Mock side-effect modules to avoid bootstrap/state.ts init chain
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// Mock bootstrap/state.js to avoid realpathSync/randomUUID side effects
mock.module('src/bootstrap/state.js', () => ({
markPostCompaction: () => {},
getSdkBetas: () => [] as string[],
getSessionId: () => 'test-session-id',
}))
// Mock config to control isAutoCompactEnabled behavior
let _mockAutoCompactEnabled: boolean | undefined = true
mock.module('src/utils/config.ts', () => ({
getGlobalConfig: () => ({
autoCompactEnabled: _mockAutoCompactEnabled,
showTurnDuration: false,
}),
isConfigEnabled: () => false,
}))
// Mock context to avoid model resolution chain
mock.module('src/utils/context.ts', () => ({
getContextWindowForModel: () => 200_000,
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
}))
// Mock tokens to avoid dependency on log.ts chain
mock.module('src/utils/tokens.ts', () => ({
tokenCountWithEstimation: () => 1000,
}))
// Mock analytics/growthbook
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
}))
// Mock API modules
mock.module('src/services/api/claude.js', () => ({
getMaxOutputTokensForModel: () => 4096,
}))
mock.module('src/services/api/promptCacheBreakDetection.js', () => ({
notifyCompaction: () => {},
}))
mock.module('src/services/SessionMemory/sessionMemoryUtils.js', () => ({
setLastSummarizedMessageId: () => {},
}))
mock.module('../compact.js', () => ({
compactConversation: async () => ({
summary: 'test summary',
messages: [],
}),
ERROR_MESSAGE_USER_ABORT: 'User aborted compaction',
}))
mock.module('../postCompactCleanup.js', () => ({
runPostCompactCleanup: () => {},
}))
mock.module('../sessionMemoryCompact.js', () => ({
trySessionMemoryCompaction: async () => null,
}))
// Dynamic import after all mocks are in place
const {
AUTOCOMPACT_BUFFER_TOKENS,
MANUAL_COMPACT_BUFFER_TOKENS,
isAutoCompactEnabled,
autoCompactIfNeeded,
} = await import('../autoCompact.js')
// --- Helpers ---
function makeToolUseContext(model = 'claude-sonnet-4-20250514') {
return { options: { mainLoopModel: model } } as any
}
function makeCacheSafeParams(): any {
return {
systemPrompt: [''],
userContext: {},
systemContext: {},
toolUseContext: {},
forkContextMessages: [],
}
}
// --- Constants ---
describe('AUTOCOMPACT_BUFFER_TOKENS', () => {
test('is 25_000 (increased from 13K for system prompt + tool definition headroom)', () => {
expect(AUTOCOMPACT_BUFFER_TOKENS).toBe(25_000)
})
})
describe('MANUAL_COMPACT_BUFFER_TOKENS', () => {
test('is 10_000 (increased from 3K for /compact headroom)', () => {
expect(MANUAL_COMPACT_BUFFER_TOKENS).toBe(10_000)
})
})
// --- isAutoCompactEnabled ---
describe('isAutoCompactEnabled', () => {
const originalDisableCompact = process.env.DISABLE_COMPACT
const originalDisableAutoCompact = process.env.DISABLE_AUTO_COMPACT
afterEach(() => {
process.env.DISABLE_COMPACT = originalDisableCompact
process.env.DISABLE_AUTO_COMPACT = originalDisableAutoCompact
_mockAutoCompactEnabled = true
})
test('returns true by default when autoCompactEnabled is undefined', () => {
_mockAutoCompactEnabled = undefined
delete process.env.DISABLE_COMPACT
delete process.env.DISABLE_AUTO_COMPACT
expect(isAutoCompactEnabled()).toBe(true)
})
test('returns true when autoCompactEnabled is explicitly true', () => {
_mockAutoCompactEnabled = true
delete process.env.DISABLE_COMPACT
delete process.env.DISABLE_AUTO_COMPACT
expect(isAutoCompactEnabled()).toBe(true)
})
test('returns false when autoCompactEnabled is false', () => {
_mockAutoCompactEnabled = false
delete process.env.DISABLE_COMPACT
delete process.env.DISABLE_AUTO_COMPACT
expect(isAutoCompactEnabled()).toBe(false)
})
test('returns false when DISABLE_COMPACT is set', () => {
_mockAutoCompactEnabled = true
process.env.DISABLE_COMPACT = '1'
delete process.env.DISABLE_AUTO_COMPACT
expect(isAutoCompactEnabled()).toBe(false)
})
test('returns false when DISABLE_AUTO_COMPACT is set', () => {
_mockAutoCompactEnabled = true
delete process.env.DISABLE_COMPACT
process.env.DISABLE_AUTO_COMPACT = '1'
expect(isAutoCompactEnabled()).toBe(false)
})
test('DISABLE_COMPACT takes precedence over autoCompactEnabled setting', () => {
_mockAutoCompactEnabled = true
process.env.DISABLE_COMPACT = '1'
expect(isAutoCompactEnabled()).toBe(false)
})
})
// --- autoCompactIfNeeded ---
describe('autoCompactIfNeeded', () => {
const originalDisableCompact = process.env.DISABLE_COMPACT
beforeEach(() => {
delete process.env.DISABLE_COMPACT
_mockAutoCompactEnabled = true
})
afterEach(() => {
process.env.DISABLE_COMPACT = originalDisableCompact
_mockAutoCompactEnabled = true
})
test('returns wasCompacted=false when DISABLE_COMPACT is set', async () => {
process.env.DISABLE_COMPACT = '1'
const result = await autoCompactIfNeeded(
[],
makeToolUseContext(),
makeCacheSafeParams(),
)
expect(result.wasCompacted).toBe(false)
})
test('trips consecutiveCompactions circuit breaker at limit (2)', async () => {
const result = await autoCompactIfNeeded(
[],
makeToolUseContext(),
makeCacheSafeParams(),
undefined,
{ compacted: true, turnCounter: 5, turnId: 't1', consecutiveCompactions: 2 },
)
expect(result.wasCompacted).toBe(false)
})
test('allows compaction when consecutiveCompactions is below limit', async () => {
const result = await autoCompactIfNeeded(
[],
makeToolUseContext(),
makeCacheSafeParams(),
undefined,
{ compacted: false, turnCounter: 0, turnId: 't1', consecutiveCompactions: 1 },
)
// Passes the circuit breaker check but shouldAutoCompact returns false (low token count)
expect(result.wasCompacted).toBe(false)
expect(result.consecutiveCompactions).toBeUndefined()
})
test('trips consecutiveFailures circuit breaker at limit (3)', async () => {
const result = await autoCompactIfNeeded(
[],
makeToolUseContext(),
makeCacheSafeParams(),
undefined,
{ compacted: false, turnCounter: 0, turnId: 't1', consecutiveFailures: 3 },
)
expect(result.wasCompacted).toBe(false)
})
test('allows attempt when consecutiveFailures is below limit', async () => {
const result = await autoCompactIfNeeded(
[],
makeToolUseContext(),
makeCacheSafeParams(),
undefined,
{ compacted: false, turnCounter: 0, turnId: 't1', consecutiveFailures: 2 },
)
// Passes the failure circuit breaker but shouldAutoCompact returns false (low tokens)
expect(result.wasCompacted).toBe(false)
})
test('returns wasCompacted=false when tracking is undefined', async () => {
const result = await autoCompactIfNeeded(
[],
makeToolUseContext(),
makeCacheSafeParams(),
)
// No tracking → no circuit breakers tripped, but shouldAutoCompact returns false
expect(result.wasCompacted).toBe(false)
})
})

View File

@ -57,12 +57,25 @@ export type AutoCompactTrackingState = {
// Used as a circuit breaker to stop retrying when the context is
// irrecoverably over the limit (e.g., prompt_too_long).
consecutiveFailures?: number
// Consecutive successful autocompact runs. Reset when the user sends a
// new message (turnCounter resets in query.ts on user-driven turns).
// Prevents infinite autocompact loops when post-compact token count
// remains above the threshold (e.g. large CLAUDE.md re-injection).
consecutiveCompactions?: number
}
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
// Increased from 13_000 to 25_000 to account for system prompt (~15-25K) and
// tool definitions (~5-20K) that are NOT counted in message-body token estimates
// but ARE part of every API request. The old 13K buffer was routinely exhausted
// before autocompact could fire, causing prompt-too-long errors in long sessions.
export const AUTOCOMPACT_BUFFER_TOKENS = 25_000
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
// Increased from 3_000 to 10_000 so manual /compact has enough headroom to
// successfully send the full conversation to the summary model. At 3K, the
// compact API call itself would often hit prompt-too-long, making /compact
// unusable right when it was most needed.
export const MANUAL_COMPACT_BUFFER_TOKENS = 10_000
// Conservative estimate for tool result growth per turn.
// Typical tool results (file reads, grep, bash) average ~5-10K tokens;
@ -98,6 +111,13 @@ export function estimateMaxTurnGrowth(model: string): number {
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
// Stop trying autocompact after this many consecutive successful compactions
// on the same turn chain. A successful compaction that doesn't reduce context
// below the threshold is just as wasteful as a failed one — each attempt burns
// a full API call (~15K output tokens) for no net progress. After the limit,
// the system falls through to reactive compact on actual prompt-too-long errors.
const MAX_CONSECUTIVE_AUTOCOMPACT_COMPACTIONS = 2
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
@ -183,7 +203,7 @@ export function isAutoCompactEnabled(): boolean {
}
// Check if user has disabled auto-compact in their settings
const userConfig = getGlobalConfig()
return userConfig.autoCompactEnabled
return userConfig.autoCompactEnabled ?? true
}
export async function shouldAutoCompact(
@ -278,6 +298,7 @@ export async function autoCompactIfNeeded(
wasCompacted: boolean
compactionResult?: CompactionResult
consecutiveFailures?: number
consecutiveCompactions?: number
}> {
if (isEnvTruthy(process.env.DISABLE_COMPACT)) {
return { wasCompacted: false }
@ -293,6 +314,23 @@ export async function autoCompactIfNeeded(
return { wasCompacted: false }
}
// Circuit breaker: stop after N consecutive successful compactions on the
// same turn chain. A successful compaction that doesn't reduce context below
// the autocompact threshold will immediately re-trigger shouldAutoCompact on
// the next iteration, creating an infinite loop that burns API calls with no
// user-visible progress. Common causes: large CLAUDE.md re-injection, many
// MCP tools, or post-compact attachments exceeding the budget.
if (
tracking?.consecutiveCompactions !== undefined &&
tracking.consecutiveCompactions >= MAX_CONSECUTIVE_AUTOCOMPACT_COMPACTIONS
) {
logForDebugging(
`autocompact: consecutive-compactions circuit breaker tripped after ${tracking.consecutiveCompactions} successful compactions — context still above threshold, falling through to reactive/recovery path`,
{ level: 'warn' },
)
return { wasCompacted: false }
}
const model = toolUseContext.options.mainLoopModel
const shouldCompact = await shouldAutoCompact(
messages,
@ -335,6 +373,8 @@ export async function autoCompactIfNeeded(
return {
wasCompacted: true,
compactionResult: sessionMemoryResult,
consecutiveFailures: 0,
consecutiveCompactions: (tracking?.consecutiveCompactions ?? 0) + 1,
}
}
@ -359,6 +399,7 @@ export async function autoCompactIfNeeded(
compactionResult,
// Reset failure count on success
consecutiveFailures: 0,
consecutiveCompactions: (tracking?.consecutiveCompactions ?? 0) + 1,
}
} catch (error) {
if (!hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT)) {

View File

@ -1290,11 +1290,30 @@ export class MeasuredText {
}
private measureWrappedText(): WrappedLine[] {
const wrappedText = wrapAnsi(this.text, this.columns, {
// Protect tabs from being expanded by npm wrap-ansi in Node.js dist builds.
// Bun.wrapAnsi preserves tabs, but npm wrap-ansi expands them to spaces,
// which breaks the indexOf-based offset recovery below.
const TAB_PLACEHOLDER_CANDIDATES = ['\u2060', '\u2061', '\u2062', '\u2063']
const tabPlaceholder = TAB_PLACEHOLDER_CANDIDATES.find(
(ch) => !this.text.includes(ch),
)
const hasTabs = this.text.includes('\t')
const textForWrap =
hasTabs && tabPlaceholder
? this.text.replaceAll('\t', tabPlaceholder)
: this.text
const wrappedTextRaw = wrapAnsi(textForWrap, this.columns, {
hard: true,
trim: false,
})
const wrappedText =
hasTabs && tabPlaceholder
? wrappedTextRaw.replaceAll(tabPlaceholder, '\t')
: wrappedTextRaw
const wrappedLines: WrappedLine[] = []
let searchOffset = 0
let lastNewLinePos = -1

View File

@ -0,0 +1,89 @@
import { describe, test, expect } from 'bun:test'
import { Cursor } from '../Cursor'
describe('MeasuredText tab protection', () => {
test('Cursor.fromText with tabs does not throw', () => {
const cursor = Cursor.fromText('a\tb', 80)
expect(cursor).toBeTruthy()
expect(cursor.text).toBe('a\tb')
})
test('tab text preserves original tabs after measurement', () => {
const cursor = Cursor.fromText('hello\tworld', 80)
expect(cursor.text).toBe('hello\tworld')
// Trigger measurement
cursor.getPosition()
expect(cursor.text).toBe('hello\tworld')
})
test('multi-line tab text works', () => {
const cursor = Cursor.fromText('foo\n\tbar', 80)
expect(cursor.text).toBe('foo\n\tbar')
expect(() => cursor.getPosition()).not.toThrow()
})
test('tabs in narrow columns (triggering visual wrap) does not throw', () => {
// 10 cols is narrow enough that tabs + text will wrap
const cursor = Cursor.fromText('x\t\thello world\tfoo', 10)
expect(() => cursor.getPosition()).not.toThrow()
expect(cursor.text).toBe('x\t\thello world\tfoo')
})
test('getPosition returns valid coordinates for tab text', () => {
const cursor = Cursor.fromText('a\tb\tc', 80)
const pos = cursor.getPosition()
expect(pos.line).toBeGreaterThanOrEqual(0)
expect(pos.column).toBeGreaterThanOrEqual(0)
})
test('cursor movement on tab text does not throw', () => {
const cursor = Cursor.fromText('abc\tdef\tghi', 80)
// Move right through tab
const right1 = cursor.right()
expect(() => right1.getPosition()).not.toThrow()
const right2 = right1.right()
expect(() => right2.getPosition()).not.toThrow()
// Move to end
const end = cursor.endOfFile()
expect(() => end.getPosition()).not.toThrow()
// Move back
expect(() => end.left().getPosition()).not.toThrow()
})
test('tab text in very narrow views (1 col) does not throw', () => {
const cursor = Cursor.fromText('a\tb', 1)
expect(() => cursor.getPosition()).not.toThrow()
})
test('text with only tabs does not throw', () => {
const cursor = Cursor.fromText('\t\t', 80)
expect(() => cursor.getPosition()).not.toThrow()
expect(cursor.text).toBe('\t\t')
})
test('mixed tabs and newlines', () => {
const cursor = Cursor.fromText('a\tb\nc\td\ne\tf', 80)
expect(() => cursor.getPosition()).not.toThrow()
// Move to last line
const eof = cursor.endOfFile()
expect(eof.text).toBe('a\tb\nc\td\ne\tf')
})
test('placeholder characters in text are handled gracefully', () => {
// Text already contains \u2060 (word joiner)
const textWithPlaceholder = 'ab\u2060\tc'
const cursor = Cursor.fromText(textWithPlaceholder, 80)
expect(() => cursor.getPosition()).not.toThrow()
expect(cursor.text).toBe(textWithPlaceholder)
})
test('text with all placeholder candidates still works', () => {
// Text contains all four zero-width placeholder candidates
const allPlaceholders = 'a\u2060\u2061\u2062\u2063\tb'
const cursor = Cursor.fromText(allPlaceholders, 80)
expect(() => cursor.getPosition()).not.toThrow()
expect(cursor.text).toBe(allPlaceholders)
})
})

View File

@ -0,0 +1,94 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { debugMock } from "../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
const { clearCACertsCache, getCACertificates, validateExtraCACertsEnv } =
await import("../caCerts");
const originalNodeExtraCACerts = process.env.NODE_EXTRA_CA_CERTS;
const originalNodeOptions = process.env.NODE_OPTIONS;
let tempDir: string;
describe("getCACertificates", () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "csc-ca-certs-"));
delete process.env.NODE_EXTRA_CA_CERTS;
delete process.env.NODE_OPTIONS;
clearCACertsCache();
});
afterEach(() => {
restoreEnvVar("NODE_EXTRA_CA_CERTS", originalNodeExtraCACerts);
restoreEnvVar("NODE_OPTIONS", originalNodeOptions);
clearCACertsCache();
rmSync(tempDir, { recursive: true, force: true });
});
test("ignores missing NODE_EXTRA_CA_CERTS path", () => {
process.env.NODE_EXTRA_CA_CERTS = join(tempDir, "missing.pem");
expect(getCACertificates()).toBeUndefined();
expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
test("validates and clears missing NODE_EXTRA_CA_CERTS before TLS setup", () => {
process.env.NODE_EXTRA_CA_CERTS = join(tempDir, "missing.pem");
validateExtraCACertsEnv();
expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
test("ignores directory NODE_EXTRA_CA_CERTS path", () => {
process.env.NODE_EXTRA_CA_CERTS = tempDir;
expect(getCACertificates()).toBeUndefined();
expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
test("appends readable NODE_EXTRA_CA_CERTS file", () => {
const certPath = join(tempDir, "extra.pem");
const cert = "-----BEGIN CERTIFICATE-----\nextra\n-----END CERTIFICATE-----\n";
writeFileSync(certPath, cert);
process.env.NODE_EXTRA_CA_CERTS = certPath;
expect(getCACertificates()).toContain(cert);
expect(process.env.NODE_EXTRA_CA_CERTS).toBe(certPath);
});
test("follows NODE_EXTRA_CA_CERTS symlink to a regular file", () => {
if (process.platform === "win32") {
return;
}
const certPath = join(tempDir, "extra.pem");
const linkPath = join(tempDir, "extra-link.pem");
const cert = "-----BEGIN CERTIFICATE-----\nlinked\n-----END CERTIFICATE-----\n";
writeFileSync(certPath, cert);
symlinkSync(certPath, linkPath);
process.env.NODE_EXTRA_CA_CERTS = linkPath;
expect(getCACertificates()).toContain(cert);
expect(process.env.NODE_EXTRA_CA_CERTS).toBe(linkPath);
});
});
function restoreEnvVar(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}

View File

@ -0,0 +1,145 @@
import { mock, describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { logMock } from '../../../tests/mocks/log.js'
import { debugMock } from '../../../tests/mocks/debug.js'
// Mock bun:bundle (feature flags all off)
mock.module('bun:bundle', () => ({ feature: () => false }))
// Mock side-effect modules
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
// Mock bootstrap/state.js to avoid realpathSync/randomUUID side effects
mock.module('src/bootstrap/state.js', () => ({
markPostCompaction: () => {},
getSdkBetas: () => [] as string[],
getSessionId: () => 'test-session-id',
}))
// Mock config to avoid file system reads
mock.module('src/utils/config.ts', () => ({
getGlobalConfig: () => ({
showTurnDuration: false,
clientDataCache: {},
}),
}))
// Mock model modules to control resolution chain
mock.module('src/utils/model/model.js', () => ({
getCanonicalName: (m: string) => m,
}))
mock.module('src/utils/model/antModels.js', () => ({
resolveAntModel: () => null,
}))
// Control what getCachedCoStrictModels returns via mutable state
let _mockCoStrictModels: Array<{ id: string; contextWindow?: number }> = []
mock.module('src/costrict/provider/models.js', () => ({
getCachedCoStrictModels: () => _mockCoStrictModels,
}))
// Control what resolveCoStrictModel returns
let _mockResolveCoStrictModelResult = ''
mock.module('src/costrict/provider/modelMapping.js', () => ({
resolveCoStrictModel: (model: string) =>
_mockResolveCoStrictModelResult || model,
}))
mock.module('src/utils/model/modelCapabilities.js', () => ({
getModelCapability: () => null,
}))
// Dynamic import after all mocks
const { getContextWindowForModel, MODEL_CONTEXT_WINDOW_DEFAULT } =
await import('../context.js')
// --- getContextWindowForModel (CoStrict model support) ---
describe('getContextWindowForModel', () => {
const originalUserType = process.env.USER_TYPE
beforeEach(() => {
// Ensure USER_TYPE is not 'ant' so ant-only paths are skipped
delete process.env.USER_TYPE
delete process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS
delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT
_mockCoStrictModels = []
_mockResolveCoStrictModelResult = ''
})
afterEach(() => {
process.env.USER_TYPE = originalUserType
_mockCoStrictModels = []
_mockResolveCoStrictModelResult = ''
})
test('returns default context window when no CoStrict models cached', () => {
_mockCoStrictModels = []
const result = getContextWindowForModel('some-unknown-model')
expect(result).toBe(MODEL_CONTEXT_WINDOW_DEFAULT)
})
test('returns CoStrict model contextWindow when model is found by exact id', () => {
_mockCoStrictModels = [
{ id: 'costrict-model-x', contextWindow: 128_000 },
{ id: 'costrict-model-y', contextWindow: 256_000 },
]
const result = getContextWindowForModel('costrict-model-x')
expect(result).toBe(128_000)
})
test('returns CoStrict model contextWindow when resolved via resolveCoStrictModel', () => {
_mockCoStrictModels = [
{ id: 'costrict-mapped-model', contextWindow: 512_000 },
]
_mockResolveCoStrictModelResult = 'costrict-mapped-model'
const result = getContextWindowForModel('claude-sonnet-4')
expect(result).toBe(512_000)
})
test('prefers exact id match over resolved name when both exist', () => {
_mockCoStrictModels = [
{ id: 'claude-sonnet-4', contextWindow: 300_000 },
{ id: 'costrict-mapped', contextWindow: 400_000 },
]
_mockResolveCoStrictModelResult = 'costrict-mapped'
// exact id 'claude-sonnet-4' matches first element
const result = getContextWindowForModel('claude-sonnet-4')
expect(result).toBe(300_000)
})
test('skips CoStrict model with zero contextWindow', () => {
_mockCoStrictModels = [
{ id: 'bad-model', contextWindow: 0 },
]
const result = getContextWindowForModel('bad-model')
expect(result).toBe(MODEL_CONTEXT_WINDOW_DEFAULT)
})
test('skips CoStrict model with undefined contextWindow', () => {
_mockCoStrictModels = [
{ id: 'no-window-model' },
]
const result = getContextWindowForModel('no-window-model')
expect(result).toBe(MODEL_CONTEXT_WINDOW_DEFAULT)
})
test('skips CoStrict model with negative contextWindow', () => {
_mockCoStrictModels = [
{ id: 'negative-model', contextWindow: -1 },
]
const result = getContextWindowForModel('negative-model')
expect(result).toBe(MODEL_CONTEXT_WINDOW_DEFAULT)
})
test('returns default when CoStrict models exist but none match', () => {
_mockCoStrictModels = [
{ id: 'other-model-a', contextWindow: 100_000 },
{ id: 'other-model-b', contextWindow: 200_000 },
]
_mockResolveCoStrictModelResult = 'yet-another-model'
const result = getContextWindowForModel('nothing-matches')
expect(result).toBe(MODEL_CONTEXT_WINDOW_DEFAULT)
})
})

View File

@ -0,0 +1,98 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { createServer } from "node:net";
import { debugMock } from "../../../tests/mocks/debug";
mock.module("src/utils/debug.ts", debugMock);
const {
_resetProxyReachabilityForTesting,
_waitForProxyReachabilityForTesting,
getProxyUrl,
} = await import("../proxy");
const originalProxyEnv = {
https_proxy: process.env.https_proxy,
HTTPS_PROXY: process.env.HTTPS_PROXY,
http_proxy: process.env.http_proxy,
HTTP_PROXY: process.env.HTTP_PROXY,
};
describe("getProxyUrl", () => {
afterEach(() => {
restoreProxyEnv();
_resetProxyReachabilityForTesting();
});
test("does not use an unreachable loopback proxy", async () => {
const port = await getClosedLoopbackPort();
clearProxyEnv();
process.env.https_proxy = `http://127.0.0.1:${port}`;
expect(getProxyUrl()).toBeUndefined();
expect(await _waitForProxyReachabilityForTesting()).toBe(false);
expect(process.env.https_proxy).toBeUndefined();
expect(getProxyUrl()).toBeUndefined();
});
test("uses a loopback proxy after reachability is confirmed", async () => {
const server = createServer();
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => resolve());
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected TCP server address");
}
const proxyUrl = `http://127.0.0.1:${address.port}`;
clearProxyEnv();
process.env.https_proxy = proxyUrl;
expect(getProxyUrl()).toBeUndefined();
expect(await _waitForProxyReachabilityForTesting()).toBe(true);
expect(getProxyUrl()).toBe(proxyUrl);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
test("clears invalid proxy URLs from the current process", () => {
clearProxyEnv();
process.env.https_proxy = "not-a-url";
expect(getProxyUrl()).toBeUndefined();
expect(process.env.https_proxy).toBeUndefined();
});
});
function clearProxyEnv(): void {
for (const key of Object.keys(originalProxyEnv)) {
delete process.env[key];
}
}
function restoreProxyEnv(): void {
for (const [key, value] of Object.entries(originalProxyEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
async function getClosedLoopbackPort(): Promise<number> {
const server = createServer();
await new Promise<void>(resolve => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected TCP server address");
}
const port = address.port;
await new Promise<void>(resolve => server.close(() => resolve()));
return port;
}

View File

@ -29,14 +29,14 @@ export const getCACertificates = memoize((): string[] | undefined => {
const useSystemCA =
hasNodeOption('--use-system-ca') || hasNodeOption('--use-openssl-ca')
const extraCertsPath = process.env.NODE_EXTRA_CA_CERTS
const extraCert = readExtraCACert(process.env.NODE_EXTRA_CA_CERTS)
logForDebugging(
`CA certs: useSystemCA=${useSystemCA}, extraCertsPath=${extraCertsPath}`,
`CA certs: useSystemCA=${useSystemCA}, hasExtraCert=${extraCert !== undefined}`,
)
// If neither is set, return undefined (use runtime defaults, no override)
if (!useSystemCA && !extraCertsPath) {
if (!useSystemCA && !extraCert) {
return undefined
}
@ -61,7 +61,7 @@ export const getCACertificates = memoize((): string[] | undefined => {
logForDebugging(
`CA certs: Loaded ${certs.length} system CA certificates (--use-system-ca)`,
)
} else if (!getCACerts && !extraCertsPath) {
} else if (!getCACerts && !extraCert) {
// Under Node.js where getCACertificates doesn't exist and no extra certs,
// return undefined to let Node.js handle --use-system-ca natively.
logForDebugging(
@ -84,26 +84,52 @@ export const getCACertificates = memoize((): string[] | undefined => {
}
// Append extra certs from file
if (extraCertsPath) {
try {
const extraCert = getFsImplementation().readFileSync(extraCertsPath, {
encoding: 'utf8',
})
certs.push(extraCert)
logForDebugging(
`CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS (${extraCertsPath})`,
)
} catch (error) {
logForDebugging(
`CA certs: Failed to read NODE_EXTRA_CA_CERTS file (${extraCertsPath}): ${error}`,
{ level: 'error' },
)
}
if (extraCert) {
certs.push(extraCert)
logForDebugging(
'CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS',
)
}
return certs.length > 0 ? certs : undefined
})
export function validateExtraCACertsEnv(): void {
readExtraCACert(process.env.NODE_EXTRA_CA_CERTS)
}
function readExtraCACert(path: string | undefined): string | undefined {
if (!path) {
return undefined
}
try {
const stats = getFsImplementation().statSync(path)
if (!stats.isFile()) {
logForDebugging(
`CA certs: Ignoring NODE_EXTRA_CA_CERTS because it is not a regular file (${path})`,
{ level: 'error' },
)
clearInvalidExtraCACertsEnv(path)
return undefined
}
return getFsImplementation().readFileSync(path, { encoding: 'utf8' })
} catch (error) {
logForDebugging(
`CA certs: Ignoring unreadable NODE_EXTRA_CA_CERTS path (${path}): ${error}`,
{ level: 'error' },
)
clearInvalidExtraCACertsEnv(path)
return undefined
}
}
function clearInvalidExtraCACertsEnv(path: string): void {
if (process.env.NODE_EXTRA_CA_CERTS === path) {
delete process.env.NODE_EXTRA_CA_CERTS
}
}
/**
* Clear the CA certificates cache.
* Call this when environment variables that affect CA certs may have changed

View File

@ -241,7 +241,7 @@ export type GlobalConfig = {
editorMode?: EditorMode
bypassPermissionsModeAccepted?: boolean
hasUsedBackslashReturn?: boolean
autoCompactEnabled: boolean // Controls whether auto-compact is enabled
autoCompactEnabled?: boolean // Controls whether auto-compact is enabled
showTurnDuration: boolean // Controls whether to show turn duration message (e.g., "Cooked for 1m 6s")
/**
* @deprecated Use settings.env instead.

View File

@ -5,6 +5,8 @@ import { isEnvTruthy } from './envUtils.js'
import { getCanonicalName } from './model/model.js'
import { resolveAntModel } from './model/antModels.js'
import { getModelCapability } from './model/modelCapabilities.js'
import { getCachedCoStrictModels } from '../costrict/provider/models.js'
import { resolveCoStrictModel } from '../costrict/provider/modelMapping.js'
// Model context window size (200k tokens for all models right now)
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
@ -99,6 +101,17 @@ export function getContextWindowForModel(
return antModel.contextWindow
}
}
// CoStrict 模型:从运行时缓存获取 contextWindow
const costrictModels = getCachedCoStrictModels()
if (costrictModels.length > 0) {
const costrictModelId = resolveCoStrictModel(model)
const costrictModel = costrictModels.find(
m => m.id === model || m.id === costrictModelId,
)
if (costrictModel?.contextWindow && costrictModel.contextWindow > 0) {
return costrictModel.contextWindow
}
}
return MODEL_CONTEXT_WINDOW_DEFAULT
}

View File

@ -1,6 +1,6 @@
import type { PermissionMode } from '../permissions/PermissionMode.js'
import { capitalize } from '../stringUtils.js'
import { MODEL_ALIASES, type ModelAlias } from './aliases.js'
import { isModelFamilyAlias, MODEL_ALIASES, type ModelAlias } from './aliases.js'
import { applyBedrockRegionPrefix, getBedrockRegionPrefix } from './bedrock.js'
import {
getCanonicalName,
@ -75,7 +75,11 @@ export function getAgentModel(
exceeds200kTokens: false,
})
}
if (aliasMatchesParentTier(toolSpecifiedModel, parentModel)) {
if (
aliasMatchesParentTier(toolSpecifiedModel, parentModel) ||
// CoStrict has no haiku/sonnet/opus tiers, so inherit parent model for family aliases.
(getAPIProvider() === 'costrict' && isModelFamilyAlias(toolSpecifiedModel))
) {
return parentModel
}
const model = parseUserSpecifiedModel(toolSpecifiedModel)
@ -84,6 +88,18 @@ export function getAgentModel(
const agentModelWithExp = agentModel ?? getDefaultSubagentModel()
// CoStrict has no haiku/sonnet/opus model tiers — all family aliases inherit parent model
if (
getAPIProvider() === 'costrict' &&
isModelFamilyAlias(agentModelWithExp)
) {
return getRuntimeMainLoopModel({
permissionMode: permissionMode ?? 'default',
mainLoopModel: parentModel,
exceeds200kTokens: false,
})
}
if (agentModelWithExp === 'inherit') {
// Apply runtime model resolution for inherit to get the effective model
// This ensures agents using 'inherit' get opusplan→Opus resolution in plan mode

View File

@ -0,0 +1,99 @@
import { describe, expect, mock, test } from 'bun:test'
import { logMock } from '../../../../tests/mocks/log'
mock.module('src/utils/log.ts', logMock)
const { patternWithRoot } = await import('../filesystem')
describe('patternWithRoot', () => {
// ── Linux / macOS ────────────────────────────────────────────
describe('relative paths', () => {
test('plain relative path → root=null', () => {
const r = patternWithRoot('src/config.json', 'localSettings')
expect(r.root).toBeNull()
expect(r.relativePattern).toBe('src/config.json')
})
test('strips ./ prefix → root=null', () => {
const r = patternWithRoot('./config.json', 'localSettings')
expect(r.root).toBeNull()
expect(r.relativePattern).toBe('config.json')
})
test('glob pattern → root=null', () => {
const r = patternWithRoot('src/**/*.ts', 'localSettings')
expect(r.root).toBeNull()
expect(r.relativePattern).toBe('src/**/*.ts')
})
test('leading dotfile → root=null', () => {
const r = patternWithRoot('.env', 'localSettings')
expect(r.root).toBeNull()
expect(r.relativePattern).toBe('.env')
})
})
describe('home directory', () => {
test('~/.config/app.json → homedir root', () => {
const r = patternWithRoot('~/.config/app.json', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/.config/app.json')
})
test('~ only (no /) → falls to root=null', () => {
const r = patternWithRoot('~', 'localSettings')
expect(r.root).toBeNull()
expect(r.relativePattern).toBe('~')
})
})
describe('absolute paths', () => {
test('/home/user/config.json', () => {
const r = patternWithRoot('/home/user/config.json', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/home/user/config.json')
})
test('/etc/hosts', () => {
const r = patternWithRoot('/etc/hosts', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/etc/hosts')
})
test('/tmp/output.log', () => {
const r = patternWithRoot('/tmp/output.log', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/tmp/output.log')
})
test('/Users/shared/data.json (userSettings source)', () => {
const r = patternWithRoot('/Users/shared/data.json', 'userSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/Users/shared/data.json')
})
})
describe('// prefix (filesystem root)', () => {
test('//etc/hosts → root=/, relative=/etc/hosts', () => {
const r = patternWithRoot('//etc/hosts', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/etc/hosts')
})
test('//home/user/file.txt', () => {
const r = patternWithRoot('//home/user/file.txt', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.relativePattern).toBe('/home/user/file.txt')
})
})
describe('/c/... NOT treated as Windows drive on Linux', () => {
test('/c/Users/me/config.json → settings-root (no drive letter)', () => {
const r = patternWithRoot('/c/Users/me/config.json', 'localSettings')
expect(r.root).not.toBeNull()
expect(r.root).not.toMatch(/^[A-Za-z]:/)
expect(r.relativePattern).toBe('/c/Users/me/config.json')
})
})
})

View File

@ -0,0 +1,144 @@
import { describe, expect, mock, test } from 'bun:test'
import type { ToolPermissionContext } from '../../../Tool'
import { logMock } from '../../../../tests/mocks/log'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/platform.ts', () => ({
getPlatform: () => 'windows',
}))
mock.module('src/utils/windowsPaths.ts', () => ({
windowsPathToPosixPath: (p: string) =>
p.startsWith('\\\\')
? p.replace(/\\/g, '/')
: p
.replace(
/^([A-Za-z]):/,
(_m: string, d: string) => `/${d.toLowerCase()}`,
)
.replace(/\\/g, '/'),
}))
mock.module('src/utils/path.ts', () => ({
containsPathTraversal: (path: string) =>
/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(path),
expandPath: (path: string) => path,
getDirectoryForPath: (path: string) => {
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return slash === -1 ? path : path.slice(0, slash)
},
sanitizePath: (path: string) => path,
}))
const { matchingRuleForInput, patternWithRoot } = await import('../filesystem')
function makePermissionContext(
rules: Partial<
Pick<
ToolPermissionContext,
'alwaysAllowRules' | 'alwaysDenyRules' | 'alwaysAskRules'
>
>,
): ToolPermissionContext {
return {
mode: 'default',
additionalWorkingDirectories: new Map(),
alwaysAllowRules: rules.alwaysAllowRules ?? {},
alwaysDenyRules: rules.alwaysDenyRules ?? {},
alwaysAskRules: rules.alwaysAskRules ?? {},
isBypassPermissionsModeAvailable: true,
}
}
describe('patternWithRoot (Windows)', () => {
test('C:\\Users\\me\\config.json → root=C:\\', () => {
const r = patternWithRoot('C:\\Users\\me\\config.json', 'localSettings')
expect(r.root).toBe('C:\\')
expect(r.relativePattern).toBe('Users/me/config.json')
})
test('C:/Users/me/config.json → root=C:\\', () => {
const r = patternWithRoot('C:/Users/me/config.json', 'localSettings')
expect(r.root).toBe('C:\\')
expect(r.relativePattern).toBe('Users/me/config.json')
})
test('D:\\projects\\app\\main.ts → root=D:\\', () => {
const r = patternWithRoot('D:\\projects\\app\\main.ts', 'localSettings')
expect(r.root).toBe('D:\\')
expect(r.relativePattern).toBe('projects/app/main.ts')
})
test('/c/Users/me/config.json (POSIX) → drive root', () => {
const r = patternWithRoot('/c/Users/me/config.json', 'localSettings')
expect(r.root).toBe('C:\\')
expect(r.relativePattern).toBe('Users/me/config.json')
})
test('/d/projects/main.ts → D: drive root', () => {
const r = patternWithRoot('/d/projects/main.ts', 'localSettings')
expect(r.root).toBe('D:\\')
expect(r.relativePattern).toBe('projects/main.ts')
})
test('src/config.ts → root=null (relative on Windows too)', () => {
const r = patternWithRoot('src/config.ts', 'localSettings')
expect(r.root).toBeNull()
expect(r.relativePattern).toBe('src/config.ts')
})
})
describe('matchingRuleForInput (Windows)', () => {
test('matches deny rule written with backslash drive path', () => {
const context = makePermissionContext({
alwaysDenyRules: {
localSettings: ['Read(C:\\Users\\me\\config.json)'],
},
})
const rule = matchingRuleForInput(
'C:\\Users\\me\\config.json',
context,
'read',
'deny',
)
expect(rule).not.toBeNull()
expect(rule?.ruleBehavior).toBe('deny')
expect(rule?.ruleValue.ruleContent).toBe('C:\\Users\\me\\config.json')
})
test('matches deny rule written with slash drive path', () => {
const context = makePermissionContext({
alwaysDenyRules: {
localSettings: ['Read(C:/Users/me/config.json)'],
},
})
const rule = matchingRuleForInput(
'C:\\Users\\me\\config.json',
context,
'read',
'deny',
)
expect(rule).not.toBeNull()
expect(rule?.ruleBehavior).toBe('deny')
expect(rule?.ruleValue.ruleContent).toBe('C:/Users/me/config.json')
})
test('does not match same relative path on a different drive', () => {
const context = makePermissionContext({
alwaysDenyRules: {
localSettings: ['Read(C:\\Users\\me\\config.json)'],
},
})
const rule = matchingRuleForInput(
'D:\\Users\\me\\config.json',
context,
'read',
'deny',
)
expect(rule).toBeNull()
})
})

View File

@ -850,13 +850,21 @@ export function getFileReadIgnorePatterns(
return result
}
function patternWithRoot(
pattern: string,
export function patternWithRoot(
originalPattern: string,
source: PermissionRuleSource,
): {
relativePattern: string
root: string | null
} {
// On Windows, normalize the pattern to POSIX so that
// Read(C:\Users\...\config.json) and Read(C:/Users/.../config.json)
// both work — previously only //C:/Users/... was recognized.
let pattern = originalPattern
if (getPlatform() === 'windows' && /^[A-Za-z]:[\\/]/.test(originalPattern)) {
pattern = windowsPathToPosixPath(originalPattern)
}
if (pattern.startsWith(`${DIR_SEP}${DIR_SEP}`)) {
// Patterns starting with // resolve relative to /
const patternWithoutDoubleSlash = pattern.slice(1)
@ -868,18 +876,12 @@ function patternWithRoot(
getPlatform() === 'windows' &&
patternWithoutDoubleSlash.match(/^\/[a-z]\//i)
) {
// Convert POSIX path to Windows format
// The pattern is like /c/Users/... so we convert it to C:\Users\...
const driveLetter = patternWithoutDoubleSlash[1]?.toUpperCase() ?? 'C'
// Keep the pattern in POSIX format since relativePath returns POSIX paths
const pathAfterDrive = patternWithoutDoubleSlash.slice(2)
// Extract the drive root (C:\) and the rest of the pattern
const driveRoot = `${driveLetter}:\\`
const relativeFromDrive = pathAfterDrive.startsWith('/')
? pathAfterDrive.slice(1)
: pathAfterDrive
return {
relativePattern: relativeFromDrive,
root: driveRoot,
@ -890,28 +892,45 @@ function patternWithRoot(
relativePattern: patternWithoutDoubleSlash,
root: DIR_SEP,
}
} else if (pattern.startsWith(`~${DIR_SEP}`)) {
}
// On Windows, recognize POSIX-ified absolute drive paths like /c/Users/...
// (produced by windowsPathToPosixPath above) as absolute, using the drive
// as root. Must come BEFORE the general pattern.startsWith('/') check so
// the drive letter is used as root, not rootPathForSource(source).
if (getPlatform() === 'windows' && /^\/[a-z](\/|$)/i.test(pattern)) {
const driveLetter = pattern[1]!.toUpperCase()
const relativePattern = pattern.slice(3) // skip "/c/"
return {
relativePattern,
root: `${driveLetter}:\\`,
}
}
if (pattern.startsWith(`~${DIR_SEP}`)) {
// Patterns starting with ~/ resolve relative to homedir
return {
relativePattern: pattern.slice(1),
root: homedir().normalize('NFC'),
}
} else if (pattern.startsWith(DIR_SEP)) {
// Patterns starting with / resolve relative to the directory where settings are stored (without .claude/)
}
if (pattern.startsWith(DIR_SEP)) {
// Patterns starting with / resolve relative to the directory where settings are stored
return {
relativePattern: pattern,
root: rootPathForSource(source),
}
}
// No root specified, put it with all the other patterns
// Normalize patterns that start with "./" to remove the prefix
// This ensures that patterns like "./.env" match files like ".env"
let normalizedPattern = pattern
// No root specified, put it with all the other patterns.
// Normalize patterns that start with "./" to remove the prefix.
let relativePattern = pattern
if (pattern.startsWith(`.${DIR_SEP}`)) {
normalizedPattern = pattern.slice(2)
relativePattern = pattern.slice(2)
}
return {
relativePattern: normalizedPattern,
relativePattern,
root: null,
}
}

View File

@ -7,6 +7,7 @@ import type { LookupOptions } from 'dns'
import type { Agent } from 'http'
import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'
import memoize from 'lodash-es/memoize.js'
import { connect } from 'net'
import type * as undici from 'undici'
import { getCACertificates } from './caCerts.js'
import { logForDebugging } from './debug.js'
@ -55,6 +56,17 @@ export function getAddressFamily(options: LookupOptions): 0 | 4 | 6 {
}
type EnvLike = Record<string, string | undefined>
type ProxyReachabilityStatus = 'checking' | 'reachable' | 'unreachable'
const PROXY_REACHABILITY_TIMEOUT_MS = 500
let proxyReachability:
| {
url: string
status: ProxyReachabilityStatus
}
| undefined
let proxyReachabilityPromise: Promise<boolean> | undefined
/**
* Get the active proxy URL if one is configured
@ -62,7 +74,39 @@ type EnvLike = Record<string, string | undefined>
* @param env Environment variables to check (defaults to process.env for production use)
*/
export function getProxyUrl(env: EnvLike = process.env): string | undefined {
return env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY
const proxyUrl =
env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY
if (!proxyUrl || env !== process.env) {
return proxyUrl
}
if (!isValidProxyUrl(proxyUrl)) {
clearMatchingProxyEnv(proxyUrl)
return undefined
}
ensureProxyReachabilityCheck(proxyUrl)
if (proxyReachability?.url !== proxyUrl) {
return proxyUrl
}
if (proxyReachability.status === 'unreachable') {
return undefined
}
// Most stale proxy env issues come from local desktop proxies. Avoid routing
// startup traffic into a loopback proxy until a quick TCP probe confirms it
// is actually listening.
if (
proxyReachability.status === 'checking' &&
isLoopbackProxyUrl(proxyUrl)
) {
return undefined
}
return proxyUrl
}
/**
@ -327,6 +371,7 @@ let proxyInterceptorId: number | undefined
export function configureGlobalAgents(): void {
const proxyUrl = getProxyUrl()
const mtlsAgent = getMTLSAgent()
const isBunRuntime = typeof Bun !== 'undefined'
// Eject previous interceptor to avoid stacking on repeated calls
if (proxyInterceptorId !== undefined) {
@ -367,18 +412,24 @@ export function configureGlobalAgents(): void {
return config
})
// Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
getProxyAgent(proxyUrl),
)
// Bun fetch uses the per-request `proxy` option from getProxyFetchOptions().
// Avoid requiring undici here: compiled Bun binaries do not need it, and
// proxy env vars should not be the thing that makes startup depend on a
// dynamic Node-only require.
if (!isBunRuntime) {
// Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
getProxyAgent(proxyUrl),
)
}
} else if (mtlsAgent) {
// No proxy but mTLS is configured
axios.defaults.httpsAgent = mtlsAgent
// Set undici global dispatcher with mTLS
const mtlsOptions = getTLSFetchOptions()
if (mtlsOptions.dispatcher) {
if (!isBunRuntime && mtlsOptions.dispatcher) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
mtlsOptions.dispatcher,
@ -424,3 +475,133 @@ export function clearProxyCache(): void {
getProxyAgent.cache.clear?.()
logForDebugging('Cleared proxy agent cache')
}
function ensureProxyReachabilityCheck(proxyUrl: string): void {
if (
proxyReachability?.url === proxyUrl &&
proxyReachability.status !== 'unreachable'
) {
return
}
proxyReachability = {
url: proxyUrl,
status: 'checking',
}
const promise = probeProxyReachability(proxyUrl).catch(error => {
logForDebugging(`Proxy: reachability probe failed: ${error}`, {
level: 'warn',
})
return false
})
proxyReachabilityPromise = promise
void promise.then(reachable => {
if (proxyReachability?.url !== proxyUrl) {
return
}
if (reachable) {
proxyReachability = { url: proxyUrl, status: 'reachable' }
configureGlobalAgents()
return
}
proxyReachability = { url: proxyUrl, status: 'unreachable' }
clearMatchingProxyEnv(proxyUrl)
clearProxyCache()
configureGlobalAgents()
})
}
function probeProxyReachability(proxyUrl: string): Promise<boolean> {
let url: URL
try {
url = new URL(proxyUrl)
} catch {
return Promise.resolve(false)
}
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
const host = getProxyHost(url)
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.resolve(false)
}
return new Promise(resolve => {
const socket = connect({ host, port })
let done = false
const finish = (reachable: boolean) => {
if (done) return
done = true
socket.destroy()
resolve(reachable)
}
socket.setTimeout(PROXY_REACHABILITY_TIMEOUT_MS)
socket.once('connect', () => finish(true))
socket.once('timeout', () => finish(false))
socket.once('error', () => finish(false))
})
}
function isLoopbackProxyUrl(proxyUrl: string): boolean {
try {
const hostname = new URL(proxyUrl).hostname.toLowerCase()
return (
hostname === 'localhost' ||
hostname === '::1' ||
hostname === '[::1]' ||
hostname.startsWith('127.')
)
} catch {
return false
}
}
function getProxyHost(url: URL): string {
return url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname
}
function isValidProxyUrl(proxyUrl: string): boolean {
try {
const url = new URL(proxyUrl)
return (
!!url.hostname &&
(url.protocol === 'http:' || url.protocol === 'https:')
)
} catch {
return false
}
}
function clearMatchingProxyEnv(proxyUrl: string): void {
for (const key of [
'https_proxy',
'HTTPS_PROXY',
'http_proxy',
'HTTP_PROXY',
]) {
if (process.env[key] === proxyUrl) {
delete process.env[key]
}
}
logForDebugging(`Proxy: disabled unreachable proxy ${proxyUrl}`, {
level: 'warn',
})
}
export function _resetProxyReachabilityForTesting(): void {
proxyReachability = undefined
proxyReachabilityPromise = undefined
}
export async function _waitForProxyReachabilityForTesting(): Promise<
boolean | undefined
> {
return proxyReachabilityPromise
}