fix(web): address the review of the workspace-file route

Isolation is restored on the premise the review corrected: a workspace file
need not be agent-authored — a read row makes every file in a cloned
repository openable — and a same-origin active document was measured driving
/api/settings.describe to a 200 with full data. Script-capable documents go
back into an opaque origin; the preview's lost localStorage is the known cost,
and a separate serving origin is the way to retire it.

- confine(): a workspace rooted at a filesystem root has a realpath already
  ending in the separator, and the doubled prefix 403'd every child.
- turnDeliverables(): reset on the turn boundary, not only at a closing
  assistant, so an interrupted turn cannot spill into the next turn's row;
  and recognize a mutation by render intent (diff card, or generic with
  kind 'edit') so str_replace_editor's insert counts.
- 405 answers name the methods it allows.
- The e2e now cold-seeds a recorded WRITE turn, so the assembled application
  covers the Produced row, its chip's served URL, and the isolation header.
- Agent Note matched to what shipped (the row is in this PR, not deferred);
  ui-conversation README documents the new destination and the row; the
  fixture lane's dead-tab quirk and the cold-path listing cost are recorded.
This commit is contained in:
ZiyaZhang
2026-08-01 01:08:16 -07:00
parent f5d53f04b7
commit dcf485ac5c
17 changed files with 205 additions and 81 deletions

View File

@@ -241,6 +241,38 @@ describe('chat-flow derivation', () => {
expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0)
})
it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => {
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'str_replace_editor'),
// str_replace_editor's insert mutates behind a generic card, so the
// discriminant is the render intent, not the card shape alone.
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
})
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
},
})
const produced = turnDeliverables([
user(1, 'insert a line'),
inserted(2, 'i', 'notes.md'),
assistant(3, 'inserted', 1),
// Turn 2 mutates and then ends with no content text (interrupted, or its
// last text preceded the tool): its paths must not ride into turn 3.
user(4, 'now rewrite it'),
wrote(5, 'w', 'leaked.txt'),
user(6, 'and again'),
wrote(7, 'w2', 'notes.md'),
assistant(8, 'done', 3),
])
expect(produced.get(3)).toEqual(['notes.md'])
// Turn 3 lists only its own file — and `seen` did not suppress the rewrite
// of a path an earlier turn already touched.
expect(produced.get(8)).toEqual(['notes.md'])
expect([...produced.values()].flat()).not.toContain('leaked.txt')
})
it('renders the produced files under the closing message and opens one on click', () => {
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'),