mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(cli): backport Claude Code 2.1.220 hardening
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user