fix(tui): honor file reference boundaries
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
* @module @deepseek-ai/dsh-tui/file-autocomplete
|
* @module @deepseek-ai/dsh-tui/file-autocomplete
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readdir } from 'node:fs/promises'
|
import { lstat, readdir } from 'node:fs/promises'
|
||||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||||
|
|
||||||
/** Default maximum file and directory candidates rendered for one query. */
|
/** Default maximum file and directory candidates rendered for one query. */
|
||||||
@@ -205,7 +205,7 @@ export class WorkspaceFileSearch {
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<FileSearchCandidate[]> {
|
): Promise<FileSearchCandidate[]> {
|
||||||
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
||||||
const absolute = resolveDisplayDirectory(this.root, displayDirectory)
|
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal)
|
||||||
if (absolute === undefined) return []
|
if (absolute === undefined) return []
|
||||||
const entries = await readDirectory(absolute, signal)
|
const entries = await readDirectory(absolute, signal)
|
||||||
const candidates: FileSearchCandidate[] = []
|
const candidates: FileSearchCandidate[] = []
|
||||||
@@ -222,13 +222,30 @@ export class WorkspaceFileSearch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDisplayDirectory(root: string, displayDirectory: string): string | undefined {
|
async function resolveDisplayDirectory(
|
||||||
|
root: string,
|
||||||
|
displayDirectory: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<string | undefined> {
|
||||||
const resolvedRoot = resolve(root)
|
const resolvedRoot = resolve(root)
|
||||||
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
||||||
const fromRoot = relative(resolvedRoot, absolute)
|
const fromRoot = relative(resolvedRoot, absolute)
|
||||||
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
||||||
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
||||||
if (isAbsolute(fromRoot)) return undefined
|
if (isAbsolute(fromRoot)) return undefined
|
||||||
|
let current = resolvedRoot
|
||||||
|
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
current = join(current, segment)
|
||||||
|
try {
|
||||||
|
const status = await lstat(current)
|
||||||
|
signal.throwIfAborted()
|
||||||
|
if (status.isSymbolicLink() || !status.isDirectory()) return undefined
|
||||||
|
} catch (_error: unknown) {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
return absolute
|
return absolute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2485,7 +2485,7 @@ export function createTuiChat(
|
|||||||
// Tool visibility can change dynamically or by agent scope. Empty
|
// Tool visibility can change dynamically or by agent scope. Empty
|
||||||
// sections are omitted by renderPrompt, so guidance never names a tool
|
// sections are omitted by renderPrompt, so guidance never names a tool
|
||||||
// that this agent cannot call.
|
// that this agent cannot call.
|
||||||
text: () => agent.ctx.tools.get('read') === undefined ? '' : FILE_REFERENCE_PROMPT,
|
text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,24 @@ describe('WorkspaceFileSearch', () => {
|
|||||||
])
|
])
|
||||||
expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
|
expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
|
||||||
expect(await files.list('../', signal)).toEqual([])
|
expect(await files.list('../', signal)).toEqual([])
|
||||||
|
expect(await files.list('README.md/', signal)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not traverse directory symlinks during direct completion', async () => {
|
||||||
|
const root = await workspace()
|
||||||
|
const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-'))
|
||||||
|
roots.push(outside)
|
||||||
|
await writeFile(join(outside, 'outside-secret.txt'), 'secret')
|
||||||
|
await symlink(
|
||||||
|
outside,
|
||||||
|
join(root, 'escape'),
|
||||||
|
process.platform === 'win32' ? 'junction' : 'dir',
|
||||||
|
)
|
||||||
|
const files = search(root)
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
|
expect(await files.list('escape/', signal)).toEqual([])
|
||||||
|
expect(await files.list('escape/outside', signal)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
|
it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
|
||||||
|
|||||||
@@ -1153,22 +1153,34 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows file-reference guidance only while read is visible to the agent', async () => {
|
it('shows file-reference guidance only while read is visible to the agent', async () => {
|
||||||
const tools: Record<string, ToolDefinition> = {}
|
const read: ToolDefinition = {
|
||||||
const result = await setup({ tools })
|
name: 'read',
|
||||||
|
description: 'Read a file.',
|
||||||
|
parameters: {},
|
||||||
|
execute: () => Promise.resolve([]),
|
||||||
|
}
|
||||||
|
let visibility: 'none' | 'global' | 'agent' = 'none'
|
||||||
|
const result = await setup({
|
||||||
|
async configureContext(ctx) {
|
||||||
|
ctx.provide('tools', {
|
||||||
|
get(name: string, scope?: Agent) {
|
||||||
|
if (name !== 'read' || visibility === 'none') return undefined
|
||||||
|
return (scope === undefined) === (visibility === 'global') ? read : undefined
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
},
|
||||||
|
})
|
||||||
const fileReferenceText = async (): Promise<string | undefined> => {
|
const fileReferenceText = async (): Promise<string | undefined> => {
|
||||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||||
return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
|
return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
expect(await fileReferenceText()).toBe('')
|
expect(await fileReferenceText()).toBe('')
|
||||||
tools.read = {
|
visibility = 'global'
|
||||||
name: 'read',
|
expect(await fileReferenceText()).toBe('')
|
||||||
description: 'Read a file.',
|
visibility = 'agent'
|
||||||
parameters: {},
|
|
||||||
execute: () => Promise.resolve([]),
|
|
||||||
}
|
|
||||||
expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
|
expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
|
||||||
delete tools.read
|
visibility = 'none'
|
||||||
expect(await fileReferenceText()).toBe('')
|
expect(await fileReferenceText()).toBe('')
|
||||||
} finally {
|
} finally {
|
||||||
await dispose(result)
|
await dispose(result)
|
||||||
|
|||||||
Reference in New Issue
Block a user