fix: reject invalid history parents on Windows and give questions exclusive composer
This commit is contained in:
1 parent
a4e77cacbe
commit
23537aaf28
9 files changed
+218
-18
No files matched your search
@@ -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)`)
|
||||
Reference in new issue
Block a user