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,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user