fix(cli): backport Claude Code 2.1.220 hardening

This commit is contained in:
程序员阿江(Relakkes)
2026-08-02 14:51:21 +08:00
parent 8cce4932de
commit badfaf52e1
15 changed files with 1036 additions and 33 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
* License: LGPL-3.0-or-later, Copyright (c) 2026 Wesley Liddick.
*/
// Keep in sync with the Claude Code version accepted by upstream billing validation.
export const CLAUDE_CODE_COMPAT_VERSION = '2.1.92'
export const CLAUDE_CODE_COMPAT_VERSION = '2.1.220'
export const CLAUDE_CODE_BILLING_HEADER_PREFIX = 'x-anthropic-billing-header:'
export function formatClaudeCodeBillingHeader(options: {
+6 -1
View File
@@ -1,14 +1,19 @@
import { describe, expect, test } from 'bun:test'
import { CLAUDE_CODE_COMPAT_VERSION } from './claudeCodeCompatibility.js'
import { getAttributionHeader } from './system.js'
describe('getAttributionHeader', () => {
test('tracks the audited upstream Claude Code compatibility release', () => {
expect(CLAUDE_CODE_COMPAT_VERSION).toBe('2.1.220')
})
test('uses Claude Code compatibility version and always includes CCH placeholder', () => {
const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
process.env.CLAUDE_CODE_ENTRYPOINT = 'cli'
try {
expect(getAttributionHeader('abc')).toBe(
'x-anthropic-billing-header: cc_version=2.1.92.abc; cc_entrypoint=cli; cch=00000;',
'x-anthropic-billing-header: cc_version=2.1.220.abc; cc_entrypoint=cli; cch=00000;',
)
} finally {
if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT
+1 -1
View File
@@ -1591,7 +1591,7 @@ describe('ProviderService', () => {
model: 'gpt-4',
max_tokens: 64,
system: [
{ type: 'text', text: 'x-anthropic-billing-header: cc_version=2.1.92.693; cc_entrypoint=cli; cch=00000;' },
{ type: 'text', text: 'x-anthropic-billing-header: cc_version=2.1.220.693; cc_entrypoint=cli; cch=00000;' },
{ type: 'text', text: 'You are a helpful assistant.' },
],
messages: [{ role: 'user', content: 'hello from proxy' }],
+1 -1
View File
@@ -12,7 +12,7 @@ import { openaiUsageToAnthropic } from '../proxy/transform/usage.js'
import { resolvePromptCacheKey } from '../proxy/promptCacheKey.js'
import type { AnthropicRequest, OpenAIChatResponse, OpenAIResponsesResponse } from '../proxy/transform/types.js'
const BILLING_HEADER = 'x-anthropic-billing-header: cc_version=2.1.92.693; cc_entrypoint=cli; cch=00000;'
const BILLING_HEADER = 'x-anthropic-billing-header: cc_version=2.1.220.693; cc_entrypoint=cli; cch=00000;'
// ─── anthropicToOpenaiChat ──────────────────────────────────────
+136
View File
@@ -5150,6 +5150,142 @@ describe('Sessions API', () => {
expect(remainingMessages).toHaveLength(0)
})
it('POST /api/sessions/:id/rewind should skip symlink and hard-link tracked paths', async () => {
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeef'
const workDir = path.join(tmpDir, 'linked-file-fixture')
const outsideDir = path.join(tmpDir, 'outside-linked-file-fixture')
const safeFile = path.join(workDir, 'safe.txt')
const missingFile = path.join(workDir, 'missing.txt')
const symlinkFile = path.join(workDir, 'symlink.txt')
const linkedDir = path.join(workDir, 'linked-dir')
const hardLinkFile = path.join(workDir, 'hard-link.txt')
const hardLinkDeleteFile = path.join(workDir, 'hard-link-delete.txt')
const outsideSafeFile = path.join(outsideDir, 'safe-absolute.txt')
const outsideRelativeFile = path.join(outsideDir, 'relative-target.txt')
const outsideSymlinkFile = path.join(outsideDir, 'symlink-target.txt')
const outsideDeleteFile = path.join(outsideDir, 'delete-target.txt')
const outsideHardLinkFile = path.join(outsideDir, 'hard-link-target.txt')
const outsideHardLinkDeleteFile = path.join(outsideDir, 'hard-link-delete-target.txt')
const userId = crypto.randomUUID()
const safeBackup = 'linked-safe@v1'
const missingBackup = 'linked-missing@v1'
const outsideSafeBackup = 'linked-safe-absolute@v1'
const outsideRelativeBackup = 'linked-relative@v1'
const symlinkBackup = 'linked-symlink@v1'
const hardLinkBackup = 'linked-hard-link@v1'
await fs.mkdir(workDir, { recursive: true })
await fs.mkdir(outsideDir, { recursive: true })
await fs.writeFile(safeFile, 'safe after\n', 'utf-8')
await fs.writeFile(outsideSafeFile, 'safe absolute after\n', 'utf-8')
await fs.writeFile(outsideRelativeFile, 'relative outside after\n', 'utf-8')
await fs.writeFile(outsideSymlinkFile, 'symlink outside after\n', 'utf-8')
await fs.writeFile(outsideDeleteFile, 'delete outside after\n', 'utf-8')
await fs.writeFile(outsideHardLinkFile, 'hard link outside after\n', 'utf-8')
await fs.writeFile(outsideHardLinkDeleteFile, 'hard link delete outside after\n', 'utf-8')
await fs.symlink(outsideSymlinkFile, symlinkFile)
await fs.symlink(outsideDir, linkedDir)
await fs.link(outsideHardLinkFile, hardLinkFile)
await fs.link(outsideHardLinkDeleteFile, hardLinkDeleteFile)
await writeFileHistoryBackup(sessionId, safeBackup, 'safe before\n')
await writeFileHistoryBackup(sessionId, missingBackup, 'missing before\n')
await writeFileHistoryBackup(sessionId, outsideSafeBackup, 'safe absolute before\n')
await writeFileHistoryBackup(sessionId, outsideRelativeBackup, 'relative before\n')
await writeFileHistoryBackup(sessionId, symlinkBackup, 'symlink before\n')
await writeFileHistoryBackup(sessionId, hardLinkBackup, 'hard link before\n')
await writeSessionFile('-tmp-api-linked-file', sessionId, [
makeSessionMetaEntry(workDir),
makeFileHistorySnapshotEntry(userId, {
'safe.txt': {
backupFileName: safeBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
'missing.txt': {
backupFileName: missingBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
[outsideSafeFile]: {
backupFileName: outsideSafeBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
'../outside-linked-file-fixture/relative-target.txt': {
backupFileName: outsideRelativeBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
'symlink.txt': {
backupFileName: symlinkBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
'linked-dir/delete-target.txt': {
backupFileName: null,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
'hard-link.txt': {
backupFileName: hardLinkBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
'hard-link-delete.txt': {
backupFileName: null,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('edit linked files', userId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE', userId),
])
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 0, dryRun: true }),
})
expect(previewRes.status).toBe(200)
const preview = await previewRes.json() as {
code: { available: boolean; filesChanged: string[] }
}
expect(preview.code.filesChanged).toEqual([
safeFile,
missingFile,
outsideSafeFile,
])
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 0 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(safeFile, 'utf-8')).toBe('safe before\n')
expect(await fs.readFile(missingFile, 'utf-8')).toBe('missing before\n')
expect(await fs.readFile(outsideSafeFile, 'utf-8')).toBe('safe absolute before\n')
expect(await fs.readFile(outsideRelativeFile, 'utf-8')).toBe(
'relative outside after\n',
)
expect(await fs.readFile(outsideSymlinkFile, 'utf-8')).toBe('symlink outside after\n')
expect(await fs.readFile(outsideDeleteFile, 'utf-8')).toBe('delete outside after\n')
expect(await fs.readFile(outsideHardLinkFile, 'utf-8')).toBe('hard link outside after\n')
expect(await fs.readFile(outsideHardLinkDeleteFile, 'utf-8')).toBe(
'hard link delete outside after\n',
)
expect((await fs.lstat(symlinkFile)).isSymbolicLink()).toBe(true)
expect((await fs.lstat(linkedDir)).isSymbolicLink()).toBe(true)
expect((await fs.stat(hardLinkFile)).nlink).toBe(2)
expect((await fs.stat(hardLinkDeleteFile)).nlink).toBe(2)
})
it('POST /api/sessions/:id/rewind should resolve checkpoint paths from the target prompt cwd', async () => {
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-ffffffffffff'
const parentDir = path.join(tmpDir, 'nested-cwd-parent')
+108 -6
View File
@@ -1,6 +1,7 @@
import type { UUID } from 'crypto'
import { chmod, copyFile, mkdir, readFile, stat, unlink } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { constants } from 'node:fs'
import { lstat, mkdir, open, readFile, realpath, stat, unlink, type FileHandle } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
import { createTwoFilesPatch, diffLines } from 'diff'
import { ApiError } from '../middleware/errorHandler.js'
import {
@@ -423,6 +424,77 @@ function isWithinBaseDir(absolutePath: string, baseDir: string): boolean {
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
async function resolveThroughExistingAncestor(filePath: string): Promise<string | null> {
let existingPath = resolve(filePath)
const missingSegments: string[] = []
while (true) {
try {
return resolve(await realpath(existingPath), ...missingSegments)
} catch (error) {
const maybeErr = error as NodeJS.ErrnoException
if (maybeErr.code !== 'ENOENT') return null
const parentPath = dirname(existingPath)
if (parentPath === existingPath) return null
missingSegments.unshift(basename(existingPath))
existingPath = parentPath
}
}
}
function findTrackedPathRoot(firstPath: string, secondPath: string): string {
let rootPath = resolve(firstPath)
while (!isWithinBaseDir(secondPath, rootPath)) {
const parentPath = dirname(rootPath)
if (parentPath === rootPath) return parse(secondPath).root
rootPath = parentPath
}
return rootPath
}
function pathsMatch(firstPath: string, secondPath: string): boolean {
const first = resolve(firstPath)
const second = resolve(secondPath)
return process.platform === 'win32'
? first.toLowerCase() === second.toLowerCase()
: first === second
}
async function isSafeTrackedPath(
checkpointBaseDir: string,
trackingPath: string,
): Promise<boolean> {
const baseDir = resolve(checkpointBaseDir)
const absolutePath = resolve(expandTrackingPath(baseDir, trackingPath))
if (!isAbsolute(trackingPath) && !isWithinBaseDir(absolutePath, baseDir)) {
return false
}
const pathRoot = findTrackedPathRoot(baseDir, absolutePath)
const [canonicalPathRoot, canonicalPath] = await Promise.all([
resolveThroughExistingAncestor(pathRoot),
resolveThroughExistingAncestor(absolutePath),
])
if (!canonicalPathRoot || !canonicalPath) return false
// Resolve the shared root once so system-level aliases above the workspace
// (for example /var -> /private/var on macOS) remain valid while links in a
// tracked path are rejected.
const expectedPath = resolve(canonicalPathRoot, relative(pathRoot, absolutePath))
if (!pathsMatch(canonicalPath, expectedPath)) return false
try {
const stats = await lstat(absolutePath)
return stats.isFile() && !stats.isSymbolicLink() && stats.nlink === 1
} catch (error) {
const maybeErr = error as NodeJS.ErrnoException
return maybeErr.code === 'ENOENT'
}
}
function normalizeTranscriptRelativePath(filePath: string): string {
return normalizeComparablePath(filePath).replace(/^\/+/, '')
}
@@ -782,17 +854,45 @@ async function hasFileChanged(
async function restoreBackupFile(
filePath: string,
backupFilePath: string,
): Promise<void> {
): Promise<boolean> {
const backupStats = await stat(backupFilePath)
const backupContent = await readFile(backupFilePath)
let targetFile: FileHandle
try {
await copyFile(backupFilePath, filePath)
targetFile = await open(filePath, constants.O_WRONLY | constants.O_NOFOLLOW)
} catch (error) {
const maybeErr = error as NodeJS.ErrnoException
if (maybeErr.code === 'ELOOP') return false
if (maybeErr.code !== 'ENOENT') throw error
await mkdir(dirname(filePath), { recursive: true })
await copyFile(backupFilePath, filePath)
try {
targetFile = await open(
filePath,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
backupStats.mode,
)
} catch (createError) {
const maybeCreateErr = createError as NodeJS.ErrnoException
if (maybeCreateErr.code !== 'EEXIST') throw createError
return false
}
}
try {
const targetStats = await targetFile.stat()
if (!targetStats.isFile() || targetStats.nlink !== 1) return false
await targetFile.truncate(0)
await targetFile.writeFile(backupContent)
await targetFile.chmod(backupStats.mode)
return true
} finally {
await targetFile.close()
}
await chmod(filePath, backupStats.mode)
}
async function buildCodePreview(
@@ -846,6 +946,7 @@ async function buildCodePreview(
if (backupFileName === undefined) continue
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
if (!(await isSafeTrackedPath(checkpointBaseDir, trackingPath))) continue
if (backupFileName === null) {
const currentContent = await readFileOrNull(absolutePath)
@@ -1117,6 +1218,7 @@ export async function executeSessionRewind(
if (backupFileName === undefined) continue
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
if (!(await isSafeTrackedPath(checkpointBaseDir, trackingPath))) continue
if (backupFileName === null) {
try {
+103 -3
View File
@@ -1,5 +1,14 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
@@ -10,7 +19,12 @@ import {
} from '../../bootstrap/state.js'
import { resetGitFileWatcher } from '../git/gitFilesystem.js'
import { resetSettingsCache } from '../settings/settingsCache.js'
import { createAgentWorktree, worktreeBranchName } from '../worktree.js'
import {
cleanupStaleAgentWorktrees,
createAgentWorktree,
removeAgentWorktree,
worktreeBranchName,
} from '../worktree.js'
let tempDir: string
let repoDir: string
@@ -47,6 +61,21 @@ function commit(message: string): void {
runGit(repoDir, ['commit', '-m', message])
}
function commitExternalWorktreesSymlink(): string {
const externalDir = join(tempDir, 'external-worktrees')
mkdirSync(externalDir)
mkdirSync(join(repoDir, '.claude'), { recursive: true })
writeRepoFile('.claude/README.md', 'worktree configuration\n')
symlinkSync(
externalDir,
join(repoDir, '.claude', 'worktrees'),
process.platform === 'win32' ? 'junction' : 'dir',
)
commit('add external worktrees symlink')
runGit(repoDir, ['push', 'origin', 'main'])
return externalDir
}
describe('createAgentWorktree', () => {
beforeEach(() => {
originalCwdState = getCwdState()
@@ -94,7 +123,8 @@ describe('createAgentWorktree', () => {
test('creates agent worktree branches without upstream tracking config', async () => {
const slug = 'agent-race-proof'
const { worktreeBranch } = await createAgentWorktree(slug)
const { gitRoot, worktreeBranch, worktreePath } =
await createAgentWorktree(slug)
expect(worktreeBranch).toBe(worktreeBranchName(slug))
@@ -113,5 +143,75 @@ describe('createAgentWorktree', () => {
expect(upstreamRemote.stdout.trim()).toBe('')
expect(upstreamMerge.exitCode).not.toBe(0)
expect(upstreamMerge.stdout.trim()).toBe('')
expect(
await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot),
).toBe(true)
expect(existsSync(worktreePath)).toBe(false)
})
test('refuses a repository-committed worktrees symlink before creating outside the repository', async () => {
const externalDir = commitExternalWorktreesSymlink()
await expect(createAgentWorktree('agent-symlink-escape')).rejects.toThrow(
'Refusing to use unsafe worktree path',
)
expect(await cleanupStaleAgentWorktrees(new Date())).toBe(0)
expect(readdirSync(externalDir)).toEqual([])
})
test('refuses to remove a worktree through a repository worktrees symlink', async () => {
const externalDir = commitExternalWorktreesSymlink()
const slug = 'agent-a1234567'
const branch = worktreeBranchName(slug)
const linkedWorktreePath = join(repoDir, '.claude', 'worktrees', slug)
const externalWorktreePath = join(externalDir, slug)
runGit(repoDir, [
'worktree',
'add',
'-b',
branch,
linkedWorktreePath,
'HEAD',
])
writeFileSync(join(externalWorktreePath, 'keep.txt'), 'must remain\n')
expect(
await removeAgentWorktree(linkedWorktreePath, branch, repoDir),
).toBe(false)
expect(readFileSync(join(externalWorktreePath, 'keep.txt'), 'utf8')).toBe(
'must remain\n',
)
})
test('stale cleanup skips a symlinked worktree entry and rejects non-child removal paths', async () => {
const externalDir = join(tempDir, 'external-agent-worktree')
const worktreesDir = join(repoDir, '.claude', 'worktrees')
const slug = 'agent-a1234567'
const linkedWorktreePath = join(worktreesDir, slug)
mkdirSync(externalDir, { recursive: true })
mkdirSync(worktreesDir, { recursive: true })
writeFileSync(join(externalDir, 'keep.txt'), 'must remain\n')
symlinkSync(
externalDir,
linkedWorktreePath,
process.platform === 'win32' ? 'junction' : 'dir',
)
expect(
await cleanupStaleAgentWorktrees(new Date(Date.now() + 60_000)),
).toBe(0)
expect(readFileSync(join(externalDir, 'keep.txt'), 'utf8')).toBe(
'must remain\n',
)
expect(
await removeAgentWorktree(
join(repoDir, '.claude', 'not-worktrees', slug),
worktreeBranchName(slug),
repoDir,
),
).toBe(false)
})
})
+4 -4
View File
@@ -13,7 +13,7 @@ describe('xxHash64Seeded', () => {
describe('signClaudeCodeCCHInString', () => {
test('replaces Anthropic system billing placeholder with deterministic 5 hex signature', () => {
const body = '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.693; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hello from proxy"}]}'
const body = '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220.693; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hello from proxy"}]}'
const signed = signClaudeCodeCCHInString(body)
expect(signed).toMatch(/cch=[0-9a-f]{5};/)
@@ -21,17 +21,17 @@ describe('signClaudeCodeCCHInString', () => {
})
test('does not touch cch placeholder outside structured billing block', () => {
const body = '{"messages":[{"role":"user","content":"please keep x-anthropic-billing-header: cc_version=2.1.92.abc; cc_entrypoint=cli; cch=00000; literal"}]}'
const body = '{"messages":[{"role":"user","content":"please keep x-anthropic-billing-header: cc_version=2.1.220.abc; cc_entrypoint=cli; cch=00000; literal"}]}'
expect(signClaudeCodeCCHInString(body)).toBe(body)
})
test('does not leave partial signatures when multiple placeholders exist', () => {
const body = '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.abc; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.def; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}'
const body = '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220.abc; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220.def; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}'
expect(signClaudeCodeCCHInString(body)).toBe(body)
})
test('does not partially sign when user text also contains a placeholder', () => {
const body = '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.abc; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"literal x-anthropic-billing-header: cc_version=2.1.92.user; cc_entrypoint=cli; cch=00000;"}]}'
const body = '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220.abc; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"literal x-anthropic-billing-header: cc_version=2.1.220.user; cc_entrypoint=cli; cch=00000;"}]}'
expect(signClaudeCodeCCHInString(body)).toBe(body)
})
})
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, expect, mock, spyOn, test } from 'bun:test'
import { access, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
let tempDir: string | null = null
afterEach(async () => {
if (!tempDir) return
await rm(tempDir, { recursive: true, force: true })
tempDir = null
})
test.serial('in-process shutdown waits for stdout drain and callback before exit', async () => {
const isolatedDir = await mkdtemp(join(tmpdir(), 'cc-haha-shutdown-direct-'))
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalHome = process.env.HOME
const originalNonessentialTraffic =
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
const originalDisableTelemetry = process.env.DISABLE_TELEMETRY
const originalExitCode = process.exitCode
process.env.CLAUDE_CONFIG_DIR = join(isolatedDir, '.claude')
process.env.HOME = isolatedDir
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
process.env.DISABLE_TELEMETRY = '1'
const { flushProcessOutput, writeToStdout } = await import('./process.js')
const { gracefulShutdown, resetShutdownState } = await import(
'./gracefulShutdown.js'
)
let writeCallback: ((error?: Error | null) => void) | undefined
spyOn(process.stdout, 'write').mockImplementation(((_data, callback) => {
writeCallback = callback as (error?: Error | null) => void
return false
}) as typeof process.stdout.write)
const exit = spyOn(process, 'exit').mockImplementation(
(() => undefined) as (code?: string | number | null) => never,
)
try {
writeToStdout('{"type":"result"}\n')
const shutdown = gracefulShutdown(0)
for (let i = 0; i < 10; i += 1) await Promise.resolve()
expect(exit).not.toHaveBeenCalled()
process.stdout.emit('drain')
await Promise.resolve()
expect(exit).not.toHaveBeenCalled()
writeCallback?.()
await shutdown
expect(exit).toHaveBeenCalledWith(0)
} finally {
resetShutdownState()
mock.restore()
await flushProcessOutput()
process.exitCode = originalExitCode
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalHome === undefined) delete process.env.HOME
else process.env.HOME = originalHome
if (originalNonessentialTraffic === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
} else {
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC =
originalNonessentialTraffic
}
if (originalDisableTelemetry === undefined) {
delete process.env.DISABLE_TELEMETRY
} else {
process.env.DISABLE_TELEMETRY = originalDisableTelemetry
}
await rm(isolatedDir, { recursive: true, force: true })
}
})
test('subprocess shutdown exits only after pending stdout is flushed', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'cc-haha-shutdown-drain-'))
const markerPath = join(tempDir, 'write-callback-completed')
const processModule = pathToFileURL(resolve('src/utils/process.ts')).href
const shutdownModule = pathToFileURL(
resolve('src/utils/gracefulShutdown.ts'),
).href
const script = `
import { writeFileSync } from 'node:fs'
const { writeToStdout } = await import(${JSON.stringify(processModule)})
const { gracefulShutdown } = await import(${JSON.stringify(shutdownModule)})
process.stdout.write = ((_data, _encoding, callback) => {
const done = typeof _encoding === 'function' ? _encoding : callback
setTimeout(() => process.stdout.emit('drain'), 25)
setTimeout(() => {
writeFileSync(${JSON.stringify(markerPath)}, 'done')
done?.()
}, 125)
return false
})
writeToStdout('{"type":"result"}\\n')
await gracefulShutdown(0)
`
const child = Bun.spawn(
[
process.execPath,
'--preload',
resolve('preload.ts'),
'-e',
script,
],
{
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: 'production',
CI: '1',
HOME: tempDir,
CLAUDE_CONFIG_DIR: join(tempDir, '.claude'),
CC_HAHA_SKIP_DOTENV: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
DISABLE_AUTOUPDATER: '1',
DISABLE_TELEMETRY: '1',
DISABLE_ERROR_REPORTING: '1',
},
stdout: 'pipe',
stderr: 'pipe',
},
)
const [exitCode, stderr] = await Promise.all([
child.exited,
new Response(child.stderr).text(),
])
expect(exitCode, stderr).toBe(0)
await access(markerPath)
}, 15_000)
+10 -2
View File
@@ -40,6 +40,10 @@ import { runCleanupFunctions } from './cleanupRegistry.js'
import { logForDebugging } from './debug.js'
import { logForDiagnosticsNoPII } from './diagLogs.js'
import { isEnvTruthy } from './envUtils.js'
import {
flushProcessOutput,
getProcessOutputDrainTimeoutMs,
} from './process.js'
import { getCurrentSessionTitle, sessionIdExists } from './sessionStorage.js'
import { sleep } from './sleep.js'
import { profileReport } from './startupProfiler.js'
@@ -410,17 +414,19 @@ export async function gracefulShutdown(
'./hooks.js'
)
const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
const outputDrainTimeoutMs = getProcessOutputDrainTimeoutMs()
// Failsafe: guarantee process exits even if cleanup hangs (e.g., MCP connections).
// Runs cleanupTerminalModes first so a hung cleanup doesn't leave the terminal dirty.
// Budget = max(5s, hook budget + 3.5s headroom for cleanup + analytics flush).
// Budget = max(5s, hook budget + 3.5s headroom for cleanup + analytics flush),
// plus a bounded drain budget that scales with output already queued.
failsafeTimer = setTimeout(
code => {
cleanupTerminalModes()
printResumeHint()
forceExit(code)
},
Math.max(5000, sessionEndTimeoutMs + 3500),
Math.max(5000, sessionEndTimeoutMs + 3500) + outputDrainTimeoutMs,
exitCode,
)
failsafeTimer.unref()
@@ -519,6 +525,8 @@ export async function gracefulShutdown(
}
}
await flushProcessOutput(outputDrainTimeoutMs)
forceExit(exitCode)
}
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
import {
flushProcessOutput,
getProcessOutputDrainTimeoutMs,
writeToStdout,
} from './process.js'
afterEach(async () => {
mock.restore()
await flushProcessOutput()
})
describe('process output draining', () => {
test.serial('waits for a backpressured write callback before reporting flushed', async () => {
let writeCallback: ((error?: Error | null) => void) | undefined
spyOn(process.stdout, 'write').mockImplementation(((_data, callback) => {
writeCallback = callback as (error?: Error | null) => void
return false
}) as typeof process.stdout.write)
writeToStdout('final stream-json result\n')
let flushed = false
const flush = flushProcessOutput().then(() => {
flushed = true
})
writeCallback?.()
await Promise.resolve()
expect(flushed).toBe(false)
process.stdout.emit('drain')
await flush
expect(flushed).toBe(true)
})
test.serial('scales the bounded drain budget with queued bytes', () => {
const callbacks: Array<(error?: Error | null) => void> = []
spyOn(process.stdout, 'write').mockImplementation(((_data, callback) => {
callbacks.push(callback as (error?: Error | null) => void)
return false
}) as typeof process.stdout.write)
writeToStdout('small')
const smallBudget = getProcessOutputDrainTimeoutMs()
writeToStdout('x'.repeat(4 * 1024 * 1024))
const largeBudget = getProcessOutputDrainTimeoutMs()
writeToStdout('x'.repeat(16 * 1024 * 1024))
const cappedBudget = getProcessOutputDrainTimeoutMs()
for (const callback of callbacks) callback()
process.stdout.emit('drain')
expect(smallBudget).toBeGreaterThanOrEqual(2000)
expect(largeBudget).toBeGreaterThan(smallBudget)
expect(cappedBudget).toBe(30_000)
})
test.serial('treats a broken stdout pipe as flushed', async () => {
const brokenPipe = Object.assign(new Error('broken pipe'), { code: 'EPIPE' })
let writeCount = 0
spyOn(process.stdout, 'destroy').mockImplementation(() => process.stdout)
spyOn(process.stdout, 'write').mockImplementation(((_data, _callback) => {
writeCount += 1
if (writeCount === 1) return false
throw brokenPipe
}) as typeof process.stdout.write)
writeToStdout('already queued')
expect(() => writeToStdout('tail')).not.toThrow()
await expect(flushProcessOutput()).resolves.toBeUndefined()
expect(process.stdout.destroy).toHaveBeenCalled()
})
test.serial('settles all queued writes when stdout reports EPIPE through its callback', async () => {
const callbacks: Array<(error?: Error | null) => void> = []
spyOn(process.stdout, 'destroy').mockImplementation(() => process.stdout)
spyOn(process.stdout, 'write').mockImplementation(((_data, callback) => {
callbacks.push(callback as (error?: Error | null) => void)
return false
}) as typeof process.stdout.write)
writeToStdout('first queued write')
writeToStdout('second queued write')
const brokenPipe = Object.assign(new Error('broken pipe'), { code: 'EPIPE' })
callbacks[0]?.(brokenPipe)
await expect(flushProcessOutput()).resolves.toBeUndefined()
expect(process.stdout.destroy).toHaveBeenCalled()
})
})
+150 -4
View File
@@ -3,6 +3,7 @@ function handleEPIPE(
): (err: NodeJS.ErrnoException) => void {
return (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
settlePendingWrites(stream)
stream.destroy()
}
}
@@ -14,15 +15,118 @@ export function registerProcessOutputErrorHandlers(): void {
process.stderr.on('error', handleEPIPE(process.stderr))
}
const MIN_OUTPUT_DRAIN_TIMEOUT_MS = 2000
const MAX_OUTPUT_DRAIN_TIMEOUT_MS = 30_000
// Give slow pipeline consumers 1s per 512 KiB, without allowing exit to hang forever.
const OUTPUT_DRAIN_BYTES_PER_SECOND = 512 * 1024
interface PendingWrite {
done: Promise<void>
settle: () => void
stream: NodeJS.WriteStream
}
const pendingWrites = new Set<PendingWrite>()
const drainPromises = new WeakMap<
NodeJS.WriteStream,
Promise<void>
>()
let pendingOutputBytes = 0
function settlePendingWrites(stream: NodeJS.WriteStream): void {
for (const write of [...pendingWrites]) {
if (write.stream === stream) write.settle()
}
}
function waitForStreamDrain(stream: NodeJS.WriteStream): Promise<void> {
const existing = drainPromises.get(stream)
if (existing) return existing
const drain = new Promise<void>(resolve => {
stream.once('drain', () => {
drainPromises.delete(stream)
resolve()
})
})
drainPromises.set(stream, drain)
return drain
}
function isEPIPE(error: unknown): error is NodeJS.ErrnoException {
return (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === 'EPIPE'
)
}
function writeOut(stream: NodeJS.WriteStream, data: string): void {
if (stream.destroyed) {
return
}
// Note: we don't handle backpressure (write() returning false).
//
// We should consider handling the callback to ensure we wait for data to flush.
stream.write(data /* callback to handle here */)
const bytes = Buffer.byteLength(data)
let settled = false
let callbackComplete = false
let writeResultKnown = false
let waitingForDrain = false
let resolveDone: (() => void) | undefined
const done = new Promise<void>(resolve => {
resolveDone = resolve
})
const settleIfComplete = () => {
if (settled) return
if (callbackComplete && writeResultKnown && !waitingForDrain) {
pendingWrite.settle()
}
}
const pendingWrite: PendingWrite = {
done,
settle: () => {
if (settled) return
settled = true
pendingWrites.delete(pendingWrite)
pendingOutputBytes = Math.max(0, pendingOutputBytes - bytes)
resolveDone?.()
},
stream,
}
pendingWrites.add(pendingWrite)
pendingOutputBytes += bytes
try {
const accepted = stream.write(data, error => {
if (error) {
pendingWrite.settle()
if (isEPIPE(error)) {
settlePendingWrites(stream)
stream.destroy()
}
return
}
callbackComplete = true
settleIfComplete()
})
if (settled) return
writeResultKnown = true
// Some stdout wrappers acknowledge their callback before the underlying
// stream drains, so a false return requires both signals before exit.
waitingForDrain = !accepted
if (waitingForDrain) {
void waitForStreamDrain(stream).then(() => {
waitingForDrain = false
settleIfComplete()
})
}
settleIfComplete()
} catch (error) {
pendingWrite.settle()
if (isEPIPE(error)) {
settlePendingWrites(stream)
stream.destroy()
return
}
throw error
}
}
export function writeToStdout(data: string): void {
@@ -33,6 +137,48 @@ export function writeToStderr(data: string): void {
writeOut(process.stderr, data)
}
/**
* Return the bounded time allowed to drain output already queued by this module.
* The budget grows with byte count so large stream-json results are not held to
* the same timeout as a short terminal message.
*/
export function getProcessOutputDrainTimeoutMs(): number {
if (pendingOutputBytes === 0) return 0
const byteBudgetMs = Math.ceil(
(pendingOutputBytes / OUTPUT_DRAIN_BYTES_PER_SECOND) * 1000,
)
return Math.min(
MAX_OUTPUT_DRAIN_TIMEOUT_MS,
MIN_OUTPUT_DRAIN_TIMEOUT_MS + byteBudgetMs,
)
}
/** Wait until queued stdout/stderr callbacks complete, or the bounded budget expires. */
export async function flushProcessOutput(
timeoutMs = getProcessOutputDrainTimeoutMs(),
): Promise<void> {
if (pendingOutputBytes === 0 || timeoutMs <= 0) return
const flushed = (async () => {
while (pendingWrites.size > 0) {
await Promise.all([...pendingWrites].map(write => write.done))
}
})()
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
flushed,
new Promise<void>(resolve => {
// eslint-disable-next-line no-restricted-syntax -- bounded output-drain deadline, not a sleep
timeoutId = setTimeout(resolve, timeoutMs)
}),
])
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId)
}
}
// Write error to stderr and exit with code 1. Consolidates the
// console.error + process.exit(1) pattern used in entrypoint fast-paths.
export function exitWithError(message: string): never {
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
_resetStreamJsonStdoutGuardForTesting,
installStreamJsonStdoutGuard,
} from './streamJsonStdoutGuard.js'
type WriteCallback = (error?: Error | null) => void
let savedStdoutWrite: typeof process.stdout.write
let savedStderrWrite: typeof process.stderr.write
beforeEach(() => {
_resetStreamJsonStdoutGuardForTesting()
savedStdoutWrite = process.stdout.write
savedStderrWrite = process.stderr.write
})
afterEach(() => {
_resetStreamJsonStdoutGuardForTesting()
process.stdout.write = savedStdoutWrite
process.stderr.write = savedStderrWrite
})
describe('stream-json stdout write callbacks', () => {
test.serial('waits for the original write callback even when write returns true', async () => {
let downstreamCallback: WriteCallback | undefined
process.stdout.write = ((_chunk, encodingOrCallback, callback) => {
downstreamCallback =
typeof encodingOrCallback === 'function'
? encodingOrCallback
: callback
return true
}) as typeof process.stdout.write
installStreamJsonStdoutGuard()
let callbackCalls = 0
const accepted = process.stdout.write('{"type":"result"}\n', error => {
expect(error).toBeUndefined()
callbackCalls += 1
})
expect(accepted).toBe(true)
await Promise.resolve()
expect(callbackCalls).toBe(0)
expect(downstreamCallback).toBeFunction()
downstreamCallback?.()
expect(callbackCalls).toBe(1)
})
test.serial('attaches the callback only to the final forwarded line and preserves false', () => {
const downstreamCallbacks: Array<WriteCallback | undefined> = []
process.stdout.write = ((_chunk, encodingOrCallback, callback) => {
downstreamCallbacks.push(
typeof encodingOrCallback === 'function'
? encodingOrCallback
: callback,
)
return false
}) as typeof process.stdout.write
installStreamJsonStdoutGuard()
const callbackErrors: Array<Error | null | undefined> = []
const accepted = process.stdout.write(
'{"type":"first"}\n{"type":"second"}\n',
error => callbackErrors.push(error),
)
expect(accepted).toBe(false)
expect(downstreamCallbacks).toHaveLength(2)
expect(downstreamCallbacks[0]).toBeUndefined()
expect(downstreamCallbacks[1]).toBeFunction()
const brokenPipe = Object.assign(new Error('broken pipe'), { code: 'EPIPE' })
downstreamCallbacks[1]?.(brokenPipe)
expect(callbackErrors).toEqual([brokenPipe])
})
test.serial('preserves the string encoding overload on the callback-owning write', () => {
let receivedEncoding: BufferEncoding | undefined
let downstreamCallback: WriteCallback | undefined
process.stdout.write = ((_chunk, encodingOrCallback, callback) => {
receivedEncoding = encodingOrCallback as BufferEncoding
downstreamCallback = callback
return true
}) as typeof process.stdout.write
installStreamJsonStdoutGuard()
let callbackCalls = 0
const accepted = process.stdout.write(
'{"type":"result"}\n',
'utf8',
() => {
callbackCalls += 1
},
)
expect(accepted).toBe(true)
expect(receivedEncoding).toBe('utf8')
expect(downstreamCallback).toBeFunction()
downstreamCallback?.()
expect(callbackCalls).toBe(1)
})
test.serial('completes a fully diverted write once without touching stdout', async () => {
let stdoutCalls = 0
process.stdout.write = (() => {
stdoutCalls += 1
return true
}) as typeof process.stdout.write
process.stderr.write = (() => true) as typeof process.stderr.write
installStreamJsonStdoutGuard()
let callbackCalls = 0
const accepted = process.stdout.write('not json\n', () => {
callbackCalls += 1
})
expect(accepted).toBe(true)
expect(stdoutCalls).toBe(0)
expect(callbackCalls).toBe(0)
await Promise.resolve()
expect(callbackCalls).toBe(1)
})
})
+28 -9
View File
@@ -63,15 +63,35 @@ export function installStreamJsonStdoutGuard(): void {
): boolean {
const text =
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8')
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
const encoding =
typeof chunk === 'string' && typeof encodingOrCb === 'string'
? encodingOrCb
: undefined
buffer += text
let newlineIdx: number
let wrote = true
const lines: string[] = []
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx)
lines.push(buffer.slice(0, newlineIdx))
buffer = buffer.slice(newlineIdx + 1)
if (isJsonLine(line)) {
wrote = originalWrite!(line + '\n')
}
const validLines = lines.map(isJsonLine)
const callbackLineIndex = callback ? validLines.lastIndexOf(true) : -1
let wrote = true
for (const [index, line] of lines.entries()) {
if (validLines[index]) {
const lineCallback = index === callbackLineIndex ? callback : undefined
if (encoding) {
wrote = lineCallback
? originalWrite!(line + '\n', encoding, lineCallback)
: originalWrite!(line + '\n', encoding)
} else {
wrote = lineCallback
? originalWrite!(line + '\n', lineCallback)
: originalWrite!(line + '\n')
}
} else {
process.stderr.write(`${STDOUT_GUARD_MARKER} ${line}\n`)
logForDebugging(
@@ -80,11 +100,10 @@ export function installStreamJsonStdoutGuard(): void {
}
}
// Fire the callback once buffering is done. We report success even when
// a line was diverted — the caller's intent (emit text) was honored,
// just on a different fd.
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
if (callback) {
// When at least one line reached stdout, its final write owns the callback
// so completion means the real stream has flushed all preceding lines.
// A fully buffered/diverted write has no downstream callback to wait for.
if (callback && callbackLineIndex === -1) {
queueMicrotask(() => callback())
}
return wrote
+136 -1
View File
@@ -3,16 +3,26 @@ import chalk from 'chalk'
import { spawnSync } from 'child_process'
import {
copyFile,
lstat,
mkdir,
readdir,
readFile,
realpath,
stat,
symlink,
utimes,
writeFile,
} from 'fs/promises'
import ignore from 'ignore'
import { basename, dirname, join } from 'path'
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'path'
import { saveCurrentProjectConfig } from './config.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
@@ -206,6 +216,97 @@ function worktreesDir(repoRoot: string): string {
return join(repoRoot, '.claude', 'worktrees')
}
function isPathContainedBy(parentPath: string, candidatePath: string): boolean {
const relativePath = relative(parentPath, candidatePath)
return (
relativePath === '' ||
(!isAbsolute(relativePath) &&
relativePath !== '..' &&
!relativePath.startsWith(`..${sep}`))
)
}
/**
* Refuse managed worktree paths whose on-disk resolution cannot be proven to
* stay inside the repository. In particular, lstat is intentional here:
* stat/realpath alone would silently follow a committed symlink or Windows
* junction at .claude or .claude/worktrees.
*/
async function assertSafeWorktreeLocation(
repoRoot: string,
worktreePath?: string,
): Promise<void> {
const resolvedRepoRoot = resolve(repoRoot)
const resolvedWorktreesDir = resolve(worktreesDir(repoRoot))
const resolvedWorktreePath = worktreePath
? resolve(worktreePath)
: undefined
const displayPath = resolvedWorktreePath ?? resolvedWorktreesDir
const refuse = (reason: string): never => {
throw new Error(
`Refusing to use unsafe worktree path "${displayPath}": ${reason}`,
)
}
if (!isPathContainedBy(resolvedRepoRoot, resolvedWorktreesDir)) {
refuse('the worktrees directory is outside the repository')
}
if (
resolvedWorktreePath &&
dirname(resolvedWorktreePath) !== resolvedWorktreesDir
) {
refuse('the worktree is not a direct child of .claude/worktrees')
}
const managedPaths = [
join(resolvedRepoRoot, '.claude'),
resolvedWorktreesDir,
]
if (resolvedWorktreePath) {
managedPaths.push(resolvedWorktreePath)
}
for (const managedPath of managedPaths) {
const metadata = await lstat(managedPath).catch(error => {
if (getErrnoCode(error) === 'ENOENT') return null
return refuse(`cannot inspect ${managedPath}: ${errorMessage(error)}`)
})
if (!metadata) continue
if (metadata.isSymbolicLink()) {
refuse(`${managedPath} is a symbolic link or junction`)
}
}
let canonicalRepoRoot: string
try {
canonicalRepoRoot = await realpath(resolvedRepoRoot)
} catch (error) {
refuse(`cannot resolve the repository root: ${errorMessage(error)}`)
}
let existingAncestor = displayPath
while (true) {
let canonicalAncestor: string
try {
canonicalAncestor = await realpath(existingAncestor)
} catch (error) {
if (getErrnoCode(error) !== 'ENOENT') {
refuse(`cannot resolve ${existingAncestor}: ${errorMessage(error)}`)
}
const parent = dirname(existingAncestor)
if (parent === existingAncestor) {
refuse('no existing ancestor can be verified')
}
existingAncestor = parent
continue
}
if (!isPathContainedBy(canonicalRepoRoot, canonicalAncestor)) {
refuse('its canonical path escapes the repository')
}
break
}
}
export async function ensureWorktreesDirExcluded(repoRoot: string): Promise<void> {
const gitDir = await resolveGitDir(repoRoot)
const commonDir = gitDir ? ((await getCommonDir(gitDir)) ?? gitDir) : null
@@ -270,6 +371,8 @@ async function getOrCreateWorktree(
const worktreePath = worktreePathFor(repoRoot, slug)
const worktreeBranch = worktreeBranchName(slug)
await assertSafeWorktreeLocation(repoRoot, worktreePath)
// Fast resume path: if the worktree already exists skip fetch and creation.
// Read the .git pointer file directly (no subprocess, no upward walk) — a
// subprocess `rev-parse HEAD` burns ~15ms on spawn overhead even for a 2ms
@@ -287,6 +390,7 @@ async function getOrCreateWorktree(
// New worktree: fetch base branch then add
await ensureWorktreesDirExcluded(repoRoot)
await mkdir(worktreesDir(repoRoot), { recursive: true })
await assertSafeWorktreeLocation(repoRoot, worktreePath)
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
@@ -362,6 +466,10 @@ async function getOrCreateWorktree(
// which races when multiple agent worktrees start together.
addArgs.push('-B', worktreeBranch, worktreePath, baseSha)
// Fetching may take long enough for the managed directory to be replaced.
// Re-check immediately before handing the destination path to git.
await assertSafeWorktreeLocation(repoRoot, worktreePath)
const { code: createCode, stderr: createStderr } =
await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
if (createCode !== 0) {
@@ -873,6 +981,13 @@ export async function cleanupWorktree(): Promise<void> {
// Explicit cwd: process.chdir above does NOT update getCwd() (the state
// CWD that execFileNoThrow defaults to). If the model cd'd to a non-repo
// dir, the bare execFileNoThrow variant would fail silently here.
const gitRoot = findGitRoot(originalCwd)
if (!gitRoot) {
throw new Error(
`Refusing to remove worktree "${worktreePath}": the original repository cannot be resolved`,
)
}
await assertSafeWorktreeLocation(gitRoot, worktreePath)
const { code: removeCode, stderr: removeError } =
await execFileNoThrowWithCwd(
gitExe(),
@@ -1019,6 +1134,13 @@ export async function removeAgentWorktree(
return false
}
try {
await assertSafeWorktreeLocation(gitRoot, worktreePath)
} catch (error) {
logForDebugging(errorMessage(error), { level: 'error' })
return false
}
// Run from the main repo root, not the worktree (which we're about to delete)
const { code: removeCode, stderr: removeError } =
await execFileNoThrowWithCwd(
@@ -1099,6 +1221,12 @@ export async function cleanupStaleAgentWorktrees(
}
const dir = worktreesDir(gitRoot)
try {
await assertSafeWorktreeLocation(gitRoot)
} catch (error) {
logForDebugging(errorMessage(error), { level: 'error' })
return 0
}
let entries: string[]
try {
entries = await readdir(dir)
@@ -1120,6 +1248,13 @@ export async function cleanupStaleAgentWorktrees(
continue
}
try {
await assertSafeWorktreeLocation(gitRoot, worktreePath)
} catch (error) {
logForDebugging(errorMessage(error), { level: 'error' })
continue
}
let mtimeMs: number
try {
mtimeMs = (await stat(worktreePath)).mtimeMs