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

+32 -5
View File
@@ -1,5 +1,5 @@
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { lstat, mkdir, readFile, rename, stat as followStat, unlink, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import { BridgeError, record, type ConversionContext, type Json } from './protocol'
@@ -12,9 +12,36 @@ function validItem(x: unknown): boolean {
if (x.type === 'function_call') return typeof x.call_id === 'string' && typeof x.name === 'string' && typeof x.arguments === 'string'
return x.type === 'message' && x.role === 'assistant' && Array.isArray(x.content) && x.content.every((s: unknown) => record(s) && s.type === 'output_text' && typeof s.text === 'string')
}
async function validateParents(file: string): Promise<void> {
const parents: string[] = []
for (let parent = dirname(resolve(file)); ; parent = dirname(parent)) {
parents.push(parent)
if (dirname(parent) === parent) break
}
// Check from the root: Windows may report ENOENT (not ENOTDIR) below a file.
// Only a missing component below verified directories is a new history path.
for (const parent of parents.reverse()) {
let stat
try { stat = await lstat(parent) }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
throw error
}
// The main process owns this path; OS/user-data directory links are valid.
// Follow those links only after lstat so dangling links are not "missing".
if (stat.isSymbolicLink()) stat = await followStat(parent)
if (!stat.isDirectory()) throw failure()
}
}
async function load(file: string): Promise<Map<string, Json[] | null>> {
try {
const stat = await lstat(file)
await validateParents(file)
let stat
try { stat = await lstat(file) }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
throw error
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure()
const value = JSON.parse(await readFile(file, 'utf8'))
if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure()
@@ -27,8 +54,7 @@ async function load(file: string): Promise<Map<string, Json[] | null>> {
entries.set(entry[0], items)
}
return entries
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
} catch {
throw failure()
}
}
@@ -52,6 +78,7 @@ export async function createConversionContext(historyFile?: string): Promise<Con
const data = JSON.stringify({ version: 1, entries: [...merged] })
if (Buffer.byteLength(data) > MAX_BYTES) throw failure()
await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 })
await validateParents(historyFile)
temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp`
await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
await rename(temp, historyFile); temp = undefined
+2 -2
View File
@@ -20,6 +20,7 @@ import type { ResourceEntry } from '@shared/resources'
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
import { composeComposer } from '../workbuddy/composer/input-composition'
import { findActiveComposerInteraction } from '../workbuddy/composer/active-interaction'
import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel'
import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog'
import { withCapabilitySelection } from '../workbuddy/composer/capability-selection'
@@ -415,8 +416,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : []
const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) })
const activeInteraction = !hidden && !loading && !loadError && currentId ? messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) : undefined
const activeInteraction = findActiveComposerInteraction(messages, currentId, running, hidden || loading || !!loadError)
const respondToActive: typeof respond = (id, response) => {
const state = useChatStore.getState()
if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换'))
@@ -67,7 +67,7 @@ export interface ComposerControlsProps {
permissionSlot?: ReactNode
modeSlot?: ReactNode
expertSlot?: ReactNode
/** An active tool approval replaces, rather than floats above, normal editing. */
/** An active question or tool approval replaces, rather than floats above, normal editing. */
approvalSlot?: ReactNode
approvalKey?: string
floatingSlot?: ReactNode
@@ -146,7 +146,7 @@ export function readReferenceUris(transfer: Pick<DataTransfer, 'getData'>): stri
return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#'))
}
/** Keep each approval subtree scoped to its request identity. */
/** Keep each takeover subtree scoped to its request identity. */
function ApprovalComposerSurface({ children }: { children: ReactNode }): React.JSX.Element {
return <div className="conversation-input-area__approval" data-composer-approval="true">
{children}
@@ -0,0 +1,18 @@
import type { ChatMessage } from '../../../../shared/types'
import type { ChatInteraction } from '../../../../shared/conversation'
/** Only a live question in the selected run may take over its composer.
* Historical question cards remain read-only; retain existing approval selection.
*/
export function findActiveComposerInteraction(
messages: readonly ChatMessage[],
sessionId: string | null,
running: { runId: string; sessionId?: string } | null,
unavailable = false
): ChatInteraction | undefined {
if (unavailable || !sessionId) return undefined
return messages.flatMap(message => message.interactions ?? []).findLast(item =>
['pending', 'responding', 'error'].includes(item.status) &&
(item.kind === 'approval' || (item.kind === 'question' &&
running?.sessionId === sessionId && running.runId === item.runId)))
}
@@ -66,7 +66,7 @@ export interface ComposerQueueComposition {
export interface ComposerInputComposition {
/** The editor stays mounted so draft, attachments and capability selections survive takeover. */
mounted: true
/** Only real tool approval replaces the normal input surface. */
/** Active questions and tool approvals replace the normal input surface. */
visible: boolean
disabled: boolean
blockedByInteraction: boolean
@@ -137,7 +137,7 @@ function panelId(value: ComposerInteractionSnapshot | ComposerNoticeSnapshot | C
* data.
* 4. Tasks move into the queue header when the queue is visible, otherwise
* they stay in the unified stack above the editor.
* 5. Running state keeps its stop action semantics; approval takeover hides the normal toolbar and renders only the approval card.
* 5. Running state keeps its stop action semantics; question/approval takeover hides the normal toolbar and renders only the interaction card.
*/
export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition {
const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null
@@ -192,7 +192,7 @@ export function composeComposer(input: ComposerCompositionInput = {}): ComposerC
queue,
input: {
mounted: true,
visible: interaction?.kind !== 'approval',
visible: interaction?.kind !== 'approval' && interaction?.kind !== 'question',
disabled,
blockedByInteraction: interactionTakeover,
destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',