Merge remote-tracking branch 'origin' into fix/sdd-design-invalid-tool-params
This commit is contained in:
commit
65e55ac763
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
85
src/query.ts
85
src/query.ts
|
|
@ -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).
|
||||
|
|
|
|||
247
src/services/compact/__tests__/autoCompact.test.ts
Normal file
247
src/services/compact/__tests__/autoCompact.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
src/utils/__tests__/Cursor.test.ts
Normal file
89
src/utils/__tests__/Cursor.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
94
src/utils/__tests__/caCerts.test.ts
Normal file
94
src/utils/__tests__/caCerts.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
145
src/utils/__tests__/context.test.ts
Normal file
145
src/utils/__tests__/context.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
98
src/utils/__tests__/proxy.test.ts
Normal file
98
src/utils/__tests__/proxy.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
src/utils/permissions/__tests__/filesystem.test.ts
Normal file
99
src/utils/permissions/__tests__/filesystem.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
144
src/utils/permissions/__tests__/filesystem.windows.test.ts
Normal file
144
src/utils/permissions/__tests__/filesystem.windows.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user