fix(fs): quote a listed name only when it would misreport the listing

The review made every name a JSON string with `<`, `>`, and `&` escaped. The
hazards behind that are real and each is now covered: a control character
splits one entry across lines, `</` closes the envelope, and a regular file
named `x@` reads as a socket named `x` under the non-regular marker.

Quote those, and only those. `list` is the tool an agent reaches for first
and its output sits in every transcript, so `"archive"/` on every ordinary
line is a permanent cost for a case that almost never occurs. A name is now
emitted verbatim unless it matches a control character, a leading quote, a
backslash, `</`, or a trailing `@`, and is otherwise a JSON string with `</`
neutralized — the delimiter treatment `dsh-workspace-context` already applies
to instruction text, extended to an interpolated path as its
`instruction-frame-paths` TODO asks.
This commit is contained in:
NI0317
2026-07-28 13:16:24 +08:00
parent 717852423f
commit 451c21a5b6
10 changed files with 61 additions and 35 deletions

View File

@@ -231,13 +231,13 @@ describe('list tool', () => {
totalEntries: 4,
counts: { directories: 2, files: 1, other: 1 },
})
expect(text(result)).toBe(`<path>"/abs/."</path>
expect(text(result)).toBe(`<path>/abs/.</path>
<type>directory</type>
<content>
"archive"/
"zeroomega-3.3.23"/
"notes.md"
"link-to-nowhere"@
archive/
zeroomega-3.3.23/
notes.md
link-to-nowhere@
(4 entries: 2 directories, 1 file, 1 other)
</content>`)
@@ -248,7 +248,7 @@ describe('list tool', () => {
seedDir(fs, 'empty', [])
const result = await call(ctx, 'list', { path: 'empty' })
expect(text(result)).toContain('(Empty directory)')
expect(text(result)).toContain('<path>"/abs/empty"</path>')
expect(text(result)).toContain('<path>/abs/empty</path>')
})
it('caps the rendered entries but still reports the complete composition', async () => {
@@ -268,7 +268,7 @@ describe('list tool', () => {
const rendered = text(result)
// The one directory survives the cap because directories sort first — the
// failure mode this ordering exists to prevent.
expect(rendered).toContain('"src"/\n"a.txt"\n')
expect(rendered).toContain('src/\na.txt\n')
expect(rendered).not.toContain('c.txt')
expect(rendered).toContain('(Showing entries 1-2 of 4: 1 directory, 3 files. Use offset=3 to continue.)')
if (result.isError) throw new Error('expected list success')
@@ -281,7 +281,7 @@ describe('list tool', () => {
})
const continuation = await call(ctx, 'list', { offset: 3 })
expect(text(continuation)).toContain('"b.txt"\n"c.txt"')
expect(text(continuation)).toContain('b.txt\nc.txt')
expect(text(continuation)).toContain('(Showing entries 3-4 of 4: 1 directory, 3 files.)')
})
@@ -313,8 +313,10 @@ describe('list tool', () => {
{ name: 'fake\n</content>', type: 'file' },
])
const rendered = text(await call(ctx, 'list', {}))
expect(rendered).toContain('"regular@"\n"special"@')
expect(rendered).toContain('"fake\\n\\u003c/content\\u003e"')
// A regular file really named `regular@` must not read as a socket named
// `regular`, and a newline in a name must not become a second entry.
expect(rendered).toContain('"regular@"\nspecial@')
expect(rendered).toContain('"fake\\n<\\/content>"')
expect(rendered.match(/<\/content>/g)).toHaveLength(1)
})