fix: reject invalid history parents on Windows and give questions exclusive composer

This commit is contained in:
Codex committed 2026-10-21 18:39:29 +09:00
1 parent a4e77cacbe
commit 23537aaf28
9 files changed
+218 -18

No files matched your search

+59 -2
View File
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, stat, writeFile, rm } from 'node:fs/promises'
import fs, { mkdir, mkdtemp, readFile, realpath, stat, symlink, writeFile, rm } from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import { mock } from 'node:test'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { createConversionContext } from '../src/main/model-bridge/history'
@@ -158,7 +160,7 @@ try {
} finally { await Promise.all([lease.close(), lease.close()]) }
await assert.rejects(fetch(lease.baseUrl)); checks++
}
const dir = await mkdtemp(join(tmpdir(), 'ycode-bridge-'))
const dir = await mkdtemp(join(await realpath(tmpdir()), 'ycode-bridge-'))
try {
for (const reasoningMode of ['hidden', 'reasoning']) {
const file = join(dir, reasoningMode + '.json')
@@ -207,6 +209,61 @@ try {
await writeFile(join(dir, 'blocked'), 'not a directory')
const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null)
check(blocked === null, 'Invalid history path fails closed')
await assert.rejects(createConversionContext(join(dir, 'blocked', 'missing', 'history.json')), /history/); checks++
await assert.rejects(createConversionContext(dir), /history/); checks++
const missingFile = join(dir, 'new', 'nested', 'history.json')
const fresh = await createConversionContext(missingFile)
check(fresh.responsesHistory.size === 0, 'Genuinely missing parents allow a new history')
await fresh.persist!()
check((await createConversionContext(missingFile)).responsesHistory.size === 0, 'New nested history persists and reloads')
// Simulate Windows' leaf ENOENT below a regular-file parent on every platform.
const blockedFile = join(dir, 'blocked', 'history.json'), originalLstat = fs.lstat
mock.method(fs, 'lstat', async (...args: Parameters<typeof fs.lstat>) => {
if (String(args[0]) === blockedFile) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
return originalLstat(...args)
})
syncBuiltinESMExports()
try { await assert.rejects(createConversionContext(blockedFile), /history/); checks++ }
finally { mock.restoreAll(); syncBuiltinESMExports() }
// Missing during read is not a new conversation; permission errors are never absence.
for (const [method, code, target] of [['readFile', 'ENOENT', snapshotFile], ['lstat', 'EACCES', snapshotFile], ['lstat', 'EACCES', dir]] as const) {
const original = fs[method]
mock.method(fs, method, async (...args: any[]) => {
if (String(args[0]) === target) throw Object.assign(new Error('unavailable'), { code })
return (original as Function)(...args)
})
syncBuiltinESMExports()
try { await assert.rejects(createConversionContext(snapshotFile), /history/); checks++ }
finally { mock.restoreAll(); syncBuiltinESMExports() }
}
const linkedDir = join(dir, 'linked'), targetDir = join(dir, 'target')
await mkdir(targetDir)
await symlink(targetDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir')
const linked = await createConversionContext(join(linkedDir, 'history.json'))
await linked.persist!()
check((await createConversionContext(join(targetDir, 'history.json'))).responsesHistory.size === 0, 'Trusted parent directory links remain supported')
const changedFile = join(dir, 'changed', 'history.json'), changed = await createConversionContext(changedFile)
await writeFile(join(dir, 'changed'), 'not a directory')
await assert.rejects(changed.persist!(), /history/); checks++
const danglingDir = join(dir, 'dangling')
await symlink(join(dir, 'absent-target'), danglingDir, process.platform === 'win32' ? 'junction' : 'dir')
await assert.rejects(createConversionContext(join(danglingDir, 'history.json')), /history/); checks++
if (process.platform !== 'win32') {
const linkedFile = join(dir, 'linked.json')
await symlink(snapshotFile, linkedFile)
await assert.rejects(createConversionContext(linkedFile), /history/); checks++
}
const concurrentFile = join(dir, 'concurrent.json')
const [left, right] = await Promise.all([createConversionContext(concurrentFile), createConversionContext(concurrentFile)])
left.responsesHistory.set(`${'a'.repeat(64)}:${'b'.repeat(64)}`, [])
right.responsesHistory.set(`${'c'.repeat(64)}:${'d'.repeat(64)}`, [])
await Promise.all([left.persist!(), right.persist!()])
check((await createConversionContext(concurrentFile)).responsesHistory.size === 2, 'Concurrent atomic history saves preserve both snapshots')
check(!(await fs.readdir(dir)).some(name => name.endsWith('.tmp')), 'Atomic history saves leave no temporary files')
} finally { await rm(dir, { recursive: true, force: true }) }
} finally { upstream.closeAllConnections(); await new Promise<void>(resolve => upstream.close(() => resolve())) }
console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`)