diff --git a/.ai/contexts/README.md b/.ai/contexts/README.md index 44f49992..c233bc34 100644 --- a/.ai/contexts/README.md +++ b/.ai/contexts/README.md @@ -17,6 +17,7 @@ without re-reading `main.js`, now ~2600 LOC. | Claude CLI state files, early subagent rescan, canary tests | [cli-session-state](cli-session-state.md) | | Busy/attention/response-ready state, the session-state domain module, the icon-slot projection | [session-state](session-state.md) | | The Changes panel: git-status parser, local/remote runner, cwd resolution, no-polling refresh | [changes-view](changes-view.md) | +| The Touched tab: files a session's file tools touched, from its transcript and its subagents', checked against disk | [touched-files](touched-files.md) | | The shell inside the file panel: mount point, hidden-write exemption, shell lifecycle, the splitter | [panel-terminal](panel-terminal.md) | | How a plain terminal's shell starts: the `claude` shim per shell, the generated rcfile and `ZDOTDIR`, the typed fallback | [plain-terminal](plain-terminal.md) | | Paths in terminal output becoming links: the matcher, the openability check, `path:line` | [terminal-path-links](terminal-path-links.md) | diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index 8e0ce769..c2b20b41 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -115,6 +115,16 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing): boundary in either direction: the session's cwd is re-resolved main-side on every call, and the absolute path built from it is used and discarded there. +### Touched files (issue #309) + +| IPC | Args | Returns | Notes | +|---|---|---|---| +| `session-touched-files` | `(sessionId)` | `{ok, files, unresolved, omitted, coverage} \| {ok:false, reason?, error}` | The files the session's file tools touched, from its transcript and its subagents'. `files` rows are `{path, state, openable, tools, count, sources}` with `state` one of `present`, `gone`, `unreadable`, `refused`, `not-file`; `unresolved` rows carry the raw text and a reason, never a `path`. `reason` is `remote` or `no-transcript`. Local sessions only; a `sub:` id is refused. Full design: `.ai/contexts/touched-files.md`. | + +This is the one handler whose *output* is a list of absolute paths taken from +attacker-influenced data. Listing is not opening: the renderer opens a row +through `read-file-for-panel` and its guards, never through this IPC. + ### Misc | IPC | Notes | @@ -185,6 +195,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr | `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded | | `git-changes-file` / `git-changes-watch` / `git-changes-unwatch` / `git-changes-locate` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root and git directories come from `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`, a symlink at the target is refused before anything follows it, and **every remaining check runs on the disk-resolved path** — containment in the root, no `.git` segment, nothing inside a git directory, `isSensitivePath`, regular file | shape + disk-resolved containment + denylist — the operand is `:`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") | | `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`, plus a version token that must still match the bytes on disk; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here | +| `session-touched-files` | `isValidChangesSessionId` (no subagent shape), a plain-folder-name check on the cached folder, `resolveTouchedPath` (absolute, no UNC / `\?\` / device form, no control character; relative only against a `verifiedTranscriptCwd`), `isSensitivePathAsync` on every resolved path before its `stat` | shape + disk-resolved denylist — **stat-only**: a path that fails the denylist is listed `refused` and never stat-ed; nothing is read. The click goes to `read-file-for-panel` | | `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc | ### Sensitive-path candidates diff --git a/.ai/contexts/touched-files.md b/.ai/contexts/touched-files.md new file mode 100644 index 00000000..1b0a1ddb --- /dev/null +++ b/.ai/contexts/touched-files.md @@ -0,0 +1,126 @@ +# Context: touched-files + +**Purpose**: a per-session list of the files the session's *file tools* touched, +in the right-hand file panel, for a local session. It answers "who touched what" +where Changes cannot: outside any repository, in a directory with no git, when +sessions share a tree, and after a change was committed or reverted. Issue #309. +User-facing behavior: `docs/touched-files.md`. IPC and its guard row: +`.ai/contexts/ipc-bridge.md` ("Touched files"). + +## Key files + +| File | Role | +|---|---| +| `session-touched-files.js` | Main-side module, no electron: `extractTouches(line)`, `resolveTouchedPath(raw, {cwd})`, `collectSessionTouchedFiles(opts)` (the walk, with every dependency injected) and `listSessionTouchedFiles(sessionId, deps)` (the IPC's target resolution). | +| `public/touched-files-view.js` | Renderer: the `'touched'` tab type, its container, rows, toggle and refresh. Loaded after `file-panel.js`, which reaches it behind `typeof` (`initTouchedView`, `renderTouchedTab`, `hideTouchedView`). | +| `read-session-file.js` | `enumerateSessionFiles(folderPath)` lists the parent and subagent transcripts (both layouts); the module keeps the entries that belong to the session. | + +## What "touched" means, and what it does not + +A row exists when a transcript holds an assistant `tool_use` block named `Edit`, +`Write`, `MultiEdit` or `NotebookEdit` whose input has a non-empty string +`file_path` (`notebook_path` for `NotebookEdit`). Nothing else counts. This is a +**lower bound**: measured on one project, 522 of 2829 tool calls carried a path, +2182 were `Bash`, and in one session 385 `Bash` calls against 27 file-tool calls +made nearly every changed file invisible here. Parsing shell commands was +considered and rejected (redirections, pipes, `find -exec`, computed names: any +coverage figure would be a guess). + +So the tab says what it is where it is read: `#touched-coverage`, a static +string in the renderer (`TOUCHED_COVERAGE_TEXT`) that is in the DOM while the +list loads, when it fails and when it is empty. `test/dom-file-panel-touched.test.js` +pins it. The empty state reads "No files touched by the file tools", never +"nothing changed". Changes stays the authority on the working tree. + +The transcript records intent, not outcome: a refused `Write` is listed. Each +row is checked against the disk when the list is built (`state`): `present`, +`gone` (`ENOENT`/`ENOTDIR`), `not-file`, `unreadable` (any other error, or no +answer in 3 s), or `refused` (see below). The list is not live: it is built on +open and on the refresh button, not on every busy-to-idle edge, because a +build reads every transcript of the session (up to 256 MiB in total, then +`coverage.truncated`) and stats up to 500 paths. + +## Trust: the paths are attacker-influenced + +A sandboxed session writes its own transcripts, so every path here is data an +attacker may have chosen. Listing one is harmless; the guards are about what the +listing *does* with it. + +- **`resolveTouchedPath`** accepts an absolute path, normalised, and refuses + (the row goes to `unresolved`, with no `path` field at all): control + characters, which means C0, DEL, C1, U+2028/2029 and every `\p{Cf}` (bidi + overrides and isolates, LRM/RLM/ALM, zero-width, tag characters; one of them + makes `reporttxt.exe` display reversed while opening the real file), + over 4096 characters, a leading `~`, on Windows any leading + double separator (UNC, `\\?\`, `\\.\`: a `stat` on a UNC path reaches the + network), a rooted path with no drive and a drive-relative one. +- **A relative path** resolves only against a cwd passed through + `verifiedTranscriptCwd` (`encode-project-path.js`, #419): the transcript's own + `cwd` must encode back to the folder it sits in. The parent and each subagent + are verified on their own transcript, so a subagent in a worktree (whose cwd + encodes to another folder) leaves its relative paths `unresolved` with reason + `relative-no-cwd`. The cwd of the Changes target is not used. +- **Before any `stat`**, every resolved path goes through `isSensitivePathAsync` + (credential-directory denylist, 8.3 and `\\?\` handling, fail closed). A hit, + or a check that throws, gives `state: 'refused'` and the path is never + stat-ed. The row stays in the list so the user sees the session reached for + it. +- **Listing is not opening.** The renderer opens a row with `readFileForPanel` + (`read-file-for-panel`, its own `isSensitivePath`, regular-file, size and + binary checks) and then the ordinary file tab. Only a row with + `openable === true` **and** `state === 'present'` has a click handler; a source + check (`test/touched-files-wiring.test.js`) pins that the view calls no other + `window.api` method than `sessionTouchedFiles` and `readFileForPanel`. The + absolute path the renderer passes is one the main process returned. +- **The folder** comes from `getCachedFolder` and must be a plain name (no + separator, not `.` or `..`) before it is joined to the projects directory; a + `sub:` session id and a remote folder are refused. +- **Rendering** is `textContent` throughout, an unresolved path shows its unsafe code points as visible escapes (`\u202E`, `\u{E0041}`), `.touched-file-path` is `unicode-bidi: isolate`; sources (subagent type from the + `.meta.json` sidecar) are stripped of control characters and cut to 80. + +## Bounds + +- 500 distinct resolved rows and 500 unresolved; the overflow is a counter + (`omitted`; duplicates of an overflowing path count again), not a kept set. +- A prefilter (`tool_use` plus a quoted tool name) keeps `JSON.parse` off every + other line. A line that passes it is skipped past 4 MiB: a tool call carries + at most a model output, a few hundred KB, so a larger one is not a tool call + worth parsing. A line over 32 MiB without a newline is skipped as well. Both + are counted in `coverage.skippedLines` and the summary says so. +- 256 MiB of transcript in total across the session's files, then + `coverage.truncated`. +- **The disk check is bounded as a whole.** The sensitivity guard + (`isSensitivePathAsync`: realpath and lstat) and the `stat` run under one + 3 s timeout per path, with 8 paths in flight. A timeout makes the row + `unreadable`. A timed-out call still holds a libuv thread, so after 8 + timed-out checks no new call is issued and the remaining rows are + `unreadable` without being checked: 500 planted paths on an offline mapped + drive hold at most about 16 threads, not 500. + +## Decisions that were open in the issue + +- **Placement**: its own tab and header toggle (`Touched`, after `Changes`), + not a section of the Changes list; the issue puts changing the Changes panel + out of scope. +- **Remote sessions**: the issue is silent; local only. The IPC answers + `reason: 'remote'` and the tab shows the message. Remote transcripts are + mirrored copies, but their paths name the host's disk, which cannot be stat-ed + from here. +- **Ordering**: first touch, parent transcript first, then subagents by file + name; no timestamps. +- **Open**: a row opens the plain file viewer. It does not route to the Changes + diff when the file is also changed in the working tree (`openFileInPanel` does, + for terminal links); doing so needs a Changes target, which a file outside any + repository does not have. + +## Not covered + +- Files touched through `Bash`, MCP tools or any tool other than the four. +- Attribution between sessions sharing a directory, beyond the `sources` labels. +- A tool result that says the call failed (`is_error`) is not read. + +## If you change this, also check + +- `test/session-touched-files.test.js` (extraction, resolution, walk, target resolution), `test/dom-file-panel-touched.test.js`, `test/touched-files-wiring.test.js` +- `public/header-controls.js` (`HEADER_CONTROLS`, the icon) and `test/header-controls.test.js` +- the guard row in `.ai/contexts/ipc-bridge.md` diff --git a/.ai/shared-guidelines.md b/.ai/shared-guidelines.md index 29b91aa3..a34fa103 100644 --- a/.ai/shared-guidelines.md +++ b/.ai/shared-guidelines.md @@ -17,6 +17,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A | Read the Claude CLI's own session state files | [contexts/cli-session-state.md](contexts/cli-session-state.md) | | Change Memory/.work-files panels (CodeMirror) | [contexts/viewer-panel.md](contexts/viewer-panel.md) | | Change the Changes panel (git-status parser, local/remote runner, cwd resolution) | [contexts/changes-view.md](contexts/changes-view.md) | +| Change the Touched tab (files a session's file tools touched, from transcripts) | [contexts/touched-files.md](contexts/touched-files.md) | | Change the shell inside the file panel (mount point, hidden-write exemption, splitter) | [contexts/panel-terminal.md](contexts/panel-terminal.md) | | Change how a plain terminal's shell starts (the `claude` shim, generated rcfile / `ZDOTDIR`, the typed fallback) | [contexts/plain-terminal.md](contexts/plain-terminal.md) | | Change what a path in terminal output links to (matcher, openability check, `path:line`) | [contexts/terminal-path-links.md](contexts/terminal-path-links.md) | diff --git a/CHANGELOG.md b/CHANGELOG.md index e96968b8..c3f1cda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### New +- A session's **Touched** tab, next to Changes in the terminal header, lists the files its file tools (Edit, Write, MultiEdit, NotebookEdit) touched, its subagents' included, with what is on disk now (present, gone, unreadable) and the tools and agents behind each. It works outside any git repository. It is not the complete set of files the session changed: files changed through Bash commands or scripts are not listed, and the tab says so. Local sessions only. (#309) - With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175) ### Changed - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) diff --git a/README.md b/README.md index 85760326..c5e8a597 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ its sessions from `~/.claude/projects`. Installed builds update themselves; see | Subagent hierarchy, live status, transcripts | [Subagents](docs/subagents.md) | | Claude's file opens and proposed edits in a side panel | [IDE emulation](docs/ide-emulation.md) | | A session's git changes, with an editor | [Changes view](docs/changes-view.md) | +| The files a session's file tools touched | [Touched files](docs/touched-files.md) | | `CLAUDE.md`, memory files and `.work-files/` | [Agent Files and Work Files](docs/memory-workfiles.md) | | Activity heatmap, token counts, rate limits | [Stats](docs/activity-stats.md) | | Reopening the open sessions after a restart | [Session restore](docs/session-restore.md) | diff --git a/docs/README.md b/docs/README.md index b2312723..eb6ba3fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ links are in the [README](../README.md#download). | [Subagents](subagents.md) | Subagent rows, their live status, the read-only transcript viewer | | [IDE emulation](ide-emulation.md) | Switchboard as Claude's IDE: file opens and diffs in a side panel | | [Changes view](changes-view.md) | A session's git status and diffs, with an editor for local sessions | +| [Touched files](touched-files.md) | The files a session's file tools touched, including outside any repository | | [Agent Files and Work Files](memory-workfiles.md) | The two file tabs: `CLAUDE.md` and memory files, schedules, `.work-files/` | | [Stats](activity-stats.md) | Heatmap, totals, per-model tokens, rate limits | | [Session restore](session-restore.md) | Reopening the open sessions at the next launch | diff --git a/docs/touched-files.md b/docs/touched-files.md new file mode 100644 index 00000000..65f504eb --- /dev/null +++ b/docs/touched-files.md @@ -0,0 +1,45 @@ +# Touched Files + +**Touched** lists the files a session's file tools touched: what it created or +edited with Edit, Write, MultiEdit or NotebookEdit, including what its subagents +did. Unlike [Changes](changes-view.md), it does not need a git repository, so it +shows files outside any repository too. It is for a local session; a remote +session has no Touched list. + +## What the list is, and is not + +It is **not** the complete set of files the session changed. Files changed +through Bash commands (`sed`, a heredoc, a script) or any other tool are not +listed, and in a typical session those are most of them. The panel says so at the +top, and an empty list means "the file tools touched nothing", not "nothing +changed". [Changes](changes-view.md) is still the answer to what differs in a +working tree. + +## Opening it + +**Touched** in the terminal header, next to **Changes**, opens the panel; clicking +it again closes it. Each row shows the path, what Switchboard found on disk, the +tools used, how many times, and who made the calls: the session or a subagent. +The list is read when the panel opens and on **Refresh**; it does not update by +itself. + +## What each row says about the disk + +The transcript records what the session tried, not what happened, so every row is +checked against the disk when the list is built: + +- **present**: the file is there. Click it to open it in the file viewer. +- **gone**: the file no longer exists, or never did (a refused write). +- **not a file**, **unreadable**: it is a directory, or it could not be read. +- **refused**: the path is in a protected location, such as a credential + directory. It is listed but never opened. + +A path that cannot be tied to a file is listed under **Not resolved to a file** +with the reason: a relative path whose session directory could not be verified, +a network path, or a path with unusable characters. It cannot be opened. + +## Limits + +- At most 500 files are listed; the summary counts the rest. +- A very large transcript is read only in part, and the summary says so. +- A row opens in the same file viewer as a [path link](terminal.md#clickable-paths); the list itself never writes. diff --git a/eslint.config.js b/eslint.config.js index e41bb21a..e80e0e07 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -370,6 +370,31 @@ module.exports = [ }, }, + // see .ai/contexts/touched-files.md + { + files: ['public/file-panel.js'], + languageOptions: { + globals: { initTouchedView: 'readonly', renderTouchedTab: 'readonly', hideTouchedView: 'readonly' }, + }, + }, + { + files: ['public/touched-files-view.js'], + languageOptions: { + globals: { + FP_ICONS: 'readonly', + currentPanelSessionId: 'readonly', + handleClose: 'readonly', + getSessionState: 'readonly', + endCurrentTab: 'readonly', + destroyCurrentTab: 'readonly', + showPanel: 'readonly', + renderPanel: 'readonly', + filePanelState: 'readonly', + openFileTab: 'readonly', + }, + }, + }, + // Dual-mode helper: classic + diff --git a/public/style.css b/public/style.css index f84eea81..a48a81f5 100644 --- a/public/style.css +++ b/public/style.css @@ -4894,6 +4894,105 @@ body { display: flex; flex-direction: column; } border-top: 1px solid var(--hairline); } +/* --- Touched files tab (issue #309) --- */ +#file-panel-touched { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +#touched-coverage { + margin: 8px 8px 0; + padding: 8px 12px; + font-size: 11px; + line-height: 1.4; + color: #c8a24a; + background: rgba(255,255,255,0.03); + border: 1px solid var(--hairline); + border-radius: 10px; +} + +#touched-summary { + margin: 8px 8px 0; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-muted); + background: rgba(255,255,255,0.03); + border: 1px solid var(--hairline); + border-radius: 10px 10px 0 0; +} + +#touched-list { + flex: 1; + overflow-y: auto; + min-height: 0; + margin: 0 8px 8px; + padding: 4px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--hairline); + border-top: none; + border-radius: 0 0 10px 10px; +} + +.touched-file-row, +.touched-unresolved-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + font-size: 12px; +} + +.touched-file-row.touched-openable { + cursor: pointer; + transition: background 0.12s; +} + +.touched-file-row.touched-openable:hover { + background: var(--hairline); +} + +.touched-file-state { + flex-shrink: 0; + padding: 1px 6px; + border-radius: 5px; + border: 1px solid var(--control-border); + background: var(--control-surface); + font-size: 10px; + color: var(--text-muted); +} + +.touched-state-present { color: #3ecf5a; } +.touched-state-gone, +.touched-state-refused { color: #e05070; } +.touched-state-unreadable, +.touched-state-not-file { color: #e0a030; } + +.touched-file-path { + flex: 1; + min-width: 0; + unicode-bidi: isolate; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #d0d0e0; +} + +.touched-file-meta { + flex-shrink: 0; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'SF Mono', 'Fira Code', Menlo, monospace; + font-size: 10px; + color: var(--text-muted); +} + /* The list keeps its explicit height while the editor is open; it is the editor that takes the remaining space. */ #changes-list.changes-list-split { diff --git a/public/touched-files-view.js b/public/touched-files-view.js new file mode 100644 index 00000000..52170d7f --- /dev/null +++ b/public/touched-files-view.js @@ -0,0 +1,287 @@ +// touched-files-view.js — the "Touched" tab of the file panel — see .ai/contexts/touched-files.md +/* exported initTouchedView, renderTouchedTab, hideTouchedView */ + +let touchedContainerEl = null; +let touchedSummaryEl = null; +let touchedListEl = null; +let touchedToggleBtn = null; + +const TOUCHED_COVERAGE_TEXT = "Lists only the files this session's file tools (Edit, Write, MultiEdit, NotebookEdit) touched, subagents included. " + + 'Files changed through Bash commands, scripts or other tools are not listed: this is not the complete set of files the session changed. ' + + 'Changes shows what differs in the working tree.'; + +const TOUCHED_STATE_LABELS = { + present: 'present', + gone: 'gone', + refused: 'refused', + unreadable: 'unreadable', + 'not-file': 'not a file', +}; + +const TOUCHED_STATE_TITLES = { + present: 'On disk now. Click to open it in the file viewer.', + gone: 'This file no longer exists on disk.', + refused: 'Not opened: this path is in a protected location.', + unreadable: 'Could not be read from disk.', + 'not-file': 'Not a regular file.', +}; + +const TOUCHED_UNRESOLVED_REASONS = { + 'relative-no-cwd': "relative path, and the session's working directory could not be verified", + invalid: 'not a usable path', + 'control-character': 'contains a control character', + 'home-relative': 'starts with ~, not resolved', + 'unsupported-form': 'network or device path, not followed', + 'drive-relative': 'drive-relative path, not resolved', + 'rooted-no-drive': 'no drive letter, not resolved', +}; + +function initTouchedView(parentEl) { + touchedContainerEl = document.createElement('div'); + touchedContainerEl.id = 'file-panel-touched'; + touchedContainerEl.style.display = 'none'; + parentEl.appendChild(touchedContainerEl); + + const toolbar = document.createElement('div'); + toolbar.className = 'viewer-toolbar'; + const info = document.createElement('div'); + info.className = 'viewer-toolbar-info'; + const title = document.createElement('span'); + title.className = 'viewer-toolbar-title'; + title.textContent = 'Touched files'; + info.appendChild(title); + toolbar.appendChild(info); + + const controls = document.createElement('div'); + controls.className = 'viewer-toolbar-controls'; + const refreshBtn = document.createElement('button'); + refreshBtn.className = 'icon-btn'; + refreshBtn.id = 'touched-refresh-btn'; + refreshBtn.title = 'Read the transcripts again'; + refreshBtn.setAttribute('aria-label', refreshBtn.title); + refreshBtn.innerHTML = FP_ICONS.refresh; + refreshBtn.addEventListener('click', () => { + if (currentPanelSessionId) refreshTouched(currentPanelSessionId); + }); + controls.appendChild(refreshBtn); + const closeBtn = document.createElement('button'); + closeBtn.className = 'icon-btn fp-close-btn'; + closeBtn.innerHTML = FP_ICONS.close; + closeBtn.title = 'Close panel'; + closeBtn.addEventListener('click', handleClose); + controls.appendChild(closeBtn); + toolbar.appendChild(controls); + touchedContainerEl.appendChild(toolbar); + + const coverage = document.createElement('div'); + coverage.id = 'touched-coverage'; + coverage.textContent = TOUCHED_COVERAGE_TEXT; + touchedContainerEl.appendChild(coverage); + + touchedSummaryEl = document.createElement('div'); + touchedSummaryEl.id = 'touched-summary'; + touchedContainerEl.appendChild(touchedSummaryEl); + + touchedListEl = document.createElement('div'); + touchedListEl.id = 'touched-list'; + touchedContainerEl.appendChild(touchedListEl); + + touchedToggleBtn = createHeaderToggle({ + id: 'touched-toggle-btn', + label: 'Touched', + title: "Show the files this session's file tools touched", + icon: 'touched', + onClick: () => { + if (currentPanelSessionId) toggleTouchedTab(currentPanelSessionId); + }, + }); +} + +function hideTouchedView() { + if (touchedContainerEl) touchedContainerEl.style.display = 'none'; + setHeaderToggle(touchedToggleBtn, false); +} + +function renderTouchedTab(sessionId, tab) { + if (!touchedContainerEl) return; + const shown = !!tab && tab.type === 'touched'; + touchedContainerEl.style.display = shown ? 'flex' : 'none'; + setHeaderToggle(touchedToggleBtn, shown); + if (shown) renderTouchedContent(sessionId, tab); +} + +function toggleTouchedTab(sessionId) { + const state = getSessionState(sessionId); + if (state.currentTab && state.currentTab.type === 'touched') { + state.currentTab = null; + endCurrentTab(sessionId, state); + return; + } + return openTouchedTab(sessionId); +} + +function openTouchedTab(sessionId) { + const state = getSessionState(sessionId); + destroyCurrentTab(state); + state.currentTab = { + type: 'touched', + label: 'Touched files', + loading: true, + error: null, + data: null, + openError: null, + opening: false, + }; + state.panelVisible = true; + if (currentPanelSessionId === sessionId) { + showPanel(state); + renderPanel(sessionId); + } + return refreshTouched(sessionId); +} + +async function refreshTouched(sessionId) { + const state = filePanelState.get(sessionId); + if (!state || !state.currentTab || state.currentTab.type !== 'touched') return; + const tab = state.currentTab; + + tab.loading = true; + tab.openError = null; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + + let result; + try { + result = await window.api.sessionTouchedFiles(sessionId); + } catch (err) { + result = { ok: false, error: (err && err.message) || 'failed to read the transcripts' }; + } + + tab.loading = false; + if (!result || result.ok === false) { + tab.error = (result && result.error) || 'failed to read the transcripts'; + tab.data = null; + } else { + tab.error = null; + tab.data = result; + } + if (currentPanelSessionId === sessionId) renderPanel(sessionId); +} + +async function openTouchedFile(sessionId, tab, filePath) { + if (tab.opening) return; + tab.opening = true; + let result; + try { + result = await window.api.readFileForPanel(filePath); + } catch (err) { + result = { ok: false, error: (err && err.message) || 'could not read the file' }; + } + tab.opening = false; + const state = filePanelState.get(sessionId); + if (!state || state.currentTab !== tab) return; + if (!result || !result.ok) { + tab.openError = `${filePath}: ${(result && result.error) || 'could not read the file'}`; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return; + } + openFileTab(sessionId, { filePath, content: result.content }); +} + +function plural(n, one, many) { + return `${n} ${n === 1 ? one : many}`; +} + +function touchedSummaryText(tab) { + if (tab.loading && !tab.data) return 'Reading the transcripts…'; + if (tab.error) return tab.error; + const data = tab.data; + if (!data) return ''; + const files = data.files || []; + const total = files.length + (data.omitted || 0); + const parts = []; + if (total === 0 && (data.unresolved || []).length === 0) { + parts.push('No files touched by the file tools'); + } else { + parts.push(`${plural(total, 'file', 'files')} touched`); + } + const coverage = data.coverage || {}; + if (coverage.subagents > 0) parts.push(`including ${plural(coverage.subagents, 'subagent', 'subagents')}`); + if (data.omitted > 0) parts.push(`+${data.omitted} more not shown`); + if (coverage.malformedLines > 0) parts.push(`${plural(coverage.malformedLines, 'unreadable line', 'unreadable lines')} skipped`); + if (coverage.skippedLines > 0) parts.push(`${plural(coverage.skippedLines, 'oversized line', 'oversized lines')} skipped`); + if (coverage.truncated) parts.push('read only part of a very large transcript'); + return parts.join(' · '); +} + +function appendTouchedMeta(rowEl, entry) { + const meta = document.createElement('span'); + meta.className = 'touched-file-meta'; + const tools = Array.isArray(entry.tools) ? entry.tools.join(', ') : ''; + const sources = Array.isArray(entry.sources) ? entry.sources.join(' · ') : ''; + meta.textContent = [tools, entry.count > 1 ? `×${entry.count}` : '', sources].filter(Boolean).join(' '); + rowEl.appendChild(meta); +} + +function buildTouchedFileRow(sessionId, tab, file) { + const rowEl = document.createElement('div'); + rowEl.className = 'touched-file-row'; + rowEl.dataset.path = file.path; + const openable = file.openable === true && file.state === 'present'; + if (openable) rowEl.classList.add('touched-openable'); + rowEl.title = TOUCHED_STATE_TITLES[file.state] || 'State unknown.'; + + const stateEl = document.createElement('span'); + stateEl.className = 'touched-file-state touched-state-' + String(file.state).replace(/[^a-z-]/g, ''); + stateEl.textContent = TOUCHED_STATE_LABELS[file.state] || 'unknown'; + rowEl.appendChild(stateEl); + + const pathEl = document.createElement('span'); + pathEl.className = 'touched-file-path'; + pathEl.textContent = file.path; + rowEl.appendChild(pathEl); + appendTouchedMeta(rowEl, file); + + if (openable) rowEl.addEventListener('click', () => openTouchedFile(sessionId, tab, file.path)); + return rowEl; +} + +function buildTouchedUnresolvedRow(entry) { + const rowEl = document.createElement('div'); + rowEl.className = 'touched-unresolved-row'; + rowEl.title = 'Not resolved to a file, so it cannot be opened.'; + + const pathEl = document.createElement('span'); + pathEl.className = 'touched-file-path'; + pathEl.textContent = entry.raw; + rowEl.appendChild(pathEl); + + const reason = document.createElement('span'); + reason.className = 'touched-file-state touched-state-unresolved'; + reason.textContent = TOUCHED_UNRESOLVED_REASONS[entry.reason] || 'not resolved'; + rowEl.appendChild(reason); + appendTouchedMeta(rowEl, entry); + return rowEl; +} + +function renderTouchedContent(sessionId, tab) { + touchedSummaryEl.textContent = touchedSummaryText(tab); + if (tab.openError) { + const err = document.createElement('div'); + err.className = 'changes-error'; + err.textContent = tab.openError; + touchedSummaryEl.appendChild(err); + } + + touchedListEl.innerHTML = ''; + const data = tab.data; + if (!data) return; + for (const file of data.files || []) touchedListEl.appendChild(buildTouchedFileRow(sessionId, tab, file)); + const unresolved = data.unresolved || []; + if (unresolved.length > 0) { + const header = document.createElement('div'); + header.className = 'changes-subagent-header'; + header.textContent = 'Not resolved to a file'; + touchedListEl.appendChild(header); + for (const entry of unresolved) touchedListEl.appendChild(buildTouchedUnresolvedRow(entry)); + } +} diff --git a/session-touched-files.js b/session-touched-files.js new file mode 100644 index 00000000..599ee291 --- /dev/null +++ b/session-touched-files.js @@ -0,0 +1,317 @@ +// session-touched-files.js — the files a session's file tools touched — see .ai/contexts/touched-files.md + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { StringDecoder } = require('string_decoder'); +const { enumerateSessionFiles, readSubagentMeta } = require('./read-session-file'); +const { isValidChangesSessionId } = require('./git-changes-target'); +const { extractCwdFromJsonl } = require('./derive-project-path'); +const { verifiedTranscriptCwd } = require('./encode-project-path'); + +const TOUCH_TOOLS = Object.freeze(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); + +const MAX_PATH_LENGTH = 4096; +const MAX_RAW_DISPLAY = 300; +const MAX_FILES = 500; +const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024; +const MAX_LINE_CHARS = 32 * 1024 * 1024; +const MAX_TOOL_LINE_CHARS = 4 * 1024 * 1024; +const STAT_CONCURRENCY = 8; +const STAT_TIMEOUT_MS = 3000; +const MAX_TIMED_OUT_CHECKS = 8; + +const PREFILTER_TOOL = /"(?:Edit|Write|MultiEdit|NotebookEdit)"/; +const UNSAFE_CHARS_SOURCE = '[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029\\p{Cf}]'; +const CONTROL_CHARS = new RegExp(UNSAFE_CHARS_SOURCE, 'u'); +const CONTROL_CHARS_GLOBAL = new RegExp(UNSAFE_CHARS_SOURCE, 'gu'); +const WIN_DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/; +const WIN_DRIVE_RELATIVE = /^[A-Za-z]:/; + +function touchTarget(block) { + const input = block.input; + if (!input || typeof input !== 'object') return null; + const key = block.name === 'NotebookEdit' ? 'notebook_path' : 'file_path'; + const value = input[key]; + return typeof value === 'string' && value !== '' ? value : null; +} + +function extractTouches(line) { + const none = { touches: [], malformed: false }; + if (typeof line !== 'string' || !line.includes('tool_use') || !PREFILTER_TOOL.test(line)) return none; + if (line.length > MAX_TOOL_LINE_CHARS) return { touches: [], malformed: false, oversized: true }; + let entry; + try { + entry = JSON.parse(line); + } catch { + return { touches: [], malformed: true }; + } + if (!entry || entry.type !== 'assistant' || !entry.message || !Array.isArray(entry.message.content)) return none; + const touches = []; + for (const block of entry.message.content) { + if (!block || block.type !== 'tool_use' || !TOUCH_TOOLS.includes(block.name)) continue; + const target = touchTarget(block); + if (target !== null) touches.push({ tool: block.name, path: target }); + } + return { touches, malformed: false }; +} + +function isWindowsOps(pathOps) { + return pathOps.sep === '\\'; +} + +function isSepChar(c) { + return c === '/' || c === '\\'; +} + +// see .ai/contexts/touched-files.md ("Trust") +function resolveTouchedPath(raw, { cwd = null, pathOps = path } = {}) { + if (typeof raw !== 'string' || raw === '' || raw.length > MAX_PATH_LENGTH) return { unresolved: 'invalid' }; + if (CONTROL_CHARS.test(raw)) return { unresolved: 'control-character' }; + if (raw[0] === '~') return { unresolved: 'home-relative' }; + + let absolute; + if (isWindowsOps(pathOps)) { + if (isSepChar(raw[0]) && isSepChar(raw[1])) return { unresolved: 'unsupported-form' }; + if (WIN_DRIVE_ABSOLUTE.test(raw)) absolute = true; + else if (WIN_DRIVE_RELATIVE.test(raw)) return { unresolved: 'drive-relative' }; + else if (isSepChar(raw[0])) return { unresolved: 'rooted-no-drive' }; + else absolute = false; + } else { + absolute = pathOps.isAbsolute(raw); + } + + let resolved; + if (absolute) { + resolved = pathOps.resolve(raw); + } else { + if (typeof cwd !== 'string' || !pathOps.isAbsolute(cwd)) return { unresolved: 'relative-no-cwd' }; + resolved = pathOps.resolve(cwd, raw); + } + if (resolved.length > MAX_PATH_LENGTH) return { unresolved: 'invalid' }; + return { path: resolved }; +} + +// see .ai/contexts/touched-files.md ("Bounds") +async function* readTranscriptLines(filePath, budget) { + const decoder = new StringDecoder('utf8'); + const stream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 }); + let pending = ''; + let skipping = false; + let cut = false; + try { + for await (const chunk of stream) { + if (budget.remaining <= 0) { budget.truncated = true; cut = true; break; } + let buf = chunk; + if (buf.length > budget.remaining) { + buf = buf.subarray(0, budget.remaining); + budget.truncated = true; + cut = true; + } + budget.remaining -= buf.length; + pending += decoder.write(buf); + let nl = pending.indexOf('\n'); + while (nl !== -1) { + const line = pending.slice(0, nl); + pending = pending.slice(nl + 1); + if (skipping) skipping = false; + else yield line; + nl = pending.indexOf('\n'); + } + if (pending.length > MAX_LINE_CHARS) { pending = ''; skipping = true; budget.skipped += 1; } + if (cut) break; + } + } catch { + return; + } finally { + stream.destroy(); + } + if (!cut && !skipping && pending !== '') yield pending; +} + +async function mapLimit(items, limit, fn) { + let next = 0; + const workers = []; + for (let w = 0; w < Math.min(limit, items.length); w++) { + workers.push((async () => { + while (next < items.length) { + const i = next++; + await fn(items[i]); + } + })()); + } + await Promise.all(workers); +} + +function withTimeout(promise, ms) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +async function inspectPath(filePath, deps) { + let sensitive; + try { sensitive = await deps.isSensitive(filePath); } catch { sensitive = true; } + if (sensitive) return 'refused'; + try { + const stat = await deps.statPath(filePath); + return stat && stat.isFile() ? 'present' : 'not-file'; + } catch (err) { + return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR') ? 'gone' : 'unreadable'; + } +} + +async function diskState(filePath, deps, gate) { + if (gate.timedOut >= deps.maxTimedOutChecks) return 'unreadable'; + try { + return await withTimeout(inspectPath(filePath, deps), deps.statTimeoutMs); + } catch { + gate.timedOut += 1; + return 'unreadable'; + } +} + +function escapeUnsafe(text) { + return text.replace(CONTROL_CHARS_GLOBAL, (ch) => { + const cp = ch.codePointAt(0); + const hex = cp.toString(16).toUpperCase(); + return cp <= 0xffff ? '\\u' + hex.padStart(4, '0') : '\\u{' + hex + '}'; + }); +} + +function defaultLabel(entry) { + return entry.parentSessionId ? 'subagent ' + String(entry.sessionId).replace(/^agent-/, '') : 'session'; +} + +function safeLabel(value) { + return String(value).replace(CONTROL_CHARS_GLOBAL, ' ').slice(0, 80); +} + +function tally(map, key, make, touch, label) { + let row = map.get(key); + if (!row) { + row = make(); + map.set(key, row); + } + row.tools.add(touch.tool); + row.count += 1; + row.sources.add(label); +} + +function present(row) { + return { tools: [...row.tools].sort(), count: row.count, sources: [...row.sources] }; +} + +async function collectSessionTouchedFiles(options) { + const { + folderPath, + sessionId, + isSensitive, + cwdOf = () => null, + labelOf = defaultLabel, + enumerate = enumerateSessionFiles, + statPath = (p) => fs.promises.stat(p), + pathOps = path, + maxFiles = MAX_FILES, + maxBytes = MAX_TRANSCRIPT_BYTES, + statTimeoutMs = STAT_TIMEOUT_MS, + maxTimedOutChecks = MAX_TIMED_OUT_CHECKS, + } = options; + if (typeof isSensitive !== 'function') throw new TypeError('isSensitive is required'); + + const all = enumerate(folderPath); + const parent = all.find((e) => e.parentSessionId === null && e.sessionId === sessionId); + if (!parent) return { ok: false, reason: 'no-transcript', error: 'this session has no transcript on disk' }; + const subagents = all + .filter((e) => e.parentSessionId === sessionId) + .sort((a, b) => (a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0)); + const entries = [parent, ...subagents]; + + const caseFold = isWindowsOps(pathOps); + const resolvedRows = new Map(); + const unresolvedRows = new Map(); + let omitted = 0; + const budget = { remaining: maxBytes, truncated: false, skipped: 0 }; + let malformedLines = 0; + let oversizedLines = 0; + + for (const entry of entries) { + let cwd = null; + try { cwd = cwdOf(entry); } catch { cwd = null; } + let label; + try { label = safeLabel(labelOf(entry)); } catch { label = defaultLabel(entry); } + + for await (const line of readTranscriptLines(entry.filePath, budget)) { + const { touches, malformed, oversized } = extractTouches(line); + if (malformed) malformedLines += 1; + if (oversized) oversizedLines += 1; + for (const touch of touches) { + const resolved = resolveTouchedPath(touch.path, { cwd, pathOps }); + if (resolved.path !== undefined) { + const key = caseFold ? resolved.path.toLowerCase() : resolved.path; + if (!resolvedRows.has(key) && resolvedRows.size >= maxFiles) { omitted += 1; continue; } + tally(resolvedRows, key, () => ({ path: resolved.path, tools: new Set(), count: 0, sources: new Set() }), touch, label); + } else { + const raw = escapeUnsafe(touch.path.slice(0, MAX_RAW_DISPLAY)); + if (!unresolvedRows.has(raw) && unresolvedRows.size >= maxFiles) { omitted += 1; continue; } + tally(unresolvedRows, raw, () => ({ raw, reason: resolved.unresolved, tools: new Set(), count: 0, sources: new Set() }), touch, label); + } + } + } + } + + const files = [...resolvedRows.values()].map((row) => ({ path: row.path, state: 'unknown', openable: false, ...present(row) })); + const gate = { timedOut: 0 }; + await mapLimit(files, STAT_CONCURRENCY, async (file) => { + file.state = await diskState(file.path, { isSensitive, statPath, statTimeoutMs, maxTimedOutChecks }, gate); + file.openable = file.state === 'present'; + }); + + return { + ok: true, + files, + unresolved: [...unresolvedRows.values()].map((row) => ({ raw: row.raw, reason: row.reason, ...present(row) })), + omitted, + coverage: { + transcripts: entries.length, + subagents: subagents.length, + malformedLines, + skippedLines: oversizedLines + budget.skipped, + truncated: budget.truncated, + }, + }; +} + +function plainFolderName(folder) { + return typeof folder === 'string' && folder !== '' && folder !== '.' && folder !== '..' && !/[/\\]/.test(folder); +} + +function subagentLabel(entry) { + const shortId = String(entry.sessionId).replace(/^agent-/, '').slice(0, 7); + let type = ''; + const meta = readSubagentMeta(entry.filePath); + if (meta && typeof meta.agentType === 'string') type = meta.agentType.trim(); + return type ? `subagent ${type} (${shortId})` : `subagent ${shortId}`; +} + +async function listSessionTouchedFiles(sessionId, deps) { + if (!isValidChangesSessionId(sessionId)) return { ok: false, error: 'invalid session id' }; + let folder = null; + try { folder = deps.getCachedFolder(sessionId); } catch { folder = null; } + if (deps.isRemoteFolder(folder)) { + return { ok: false, reason: 'remote', error: 'the touched files of a remote session are not available' }; + } + if (!plainFolderName(folder)) return { ok: false, reason: 'no-transcript', error: 'this session has no transcript on disk' }; + return collectSessionTouchedFiles({ + folderPath: path.join(deps.projectsDir, folder), + sessionId, + isSensitive: deps.isSensitive, + cwdOf: (entry) => verifiedTranscriptCwd(extractCwdFromJsonl(entry.filePath), folder), + labelOf: (entry) => (entry.parentSessionId ? subagentLabel(entry) : 'session'), + }); +} + +module.exports = { TOUCH_TOOLS, extractTouches, resolveTouchedPath, collectSessionTouchedFiles, listSessionTouchedFiles }; diff --git a/test/dom-file-panel-touched.test.js b/test/dom-file-panel-touched.test.js new file mode 100644 index 00000000..ecadbf13 --- /dev/null +++ b/test/dom-file-panel-touched.test.js @@ -0,0 +1,387 @@ +'use strict'; + +// Renderer tests for the touched-files tab (public/touched-files-view.js), +// driven through the real public/file-panel.js in a jsdom window — see +// .ai/contexts/touched-files.md. The list is a lower bound by construction, +// so the tests that matter pin what the tab says about itself and what a +// click is allowed to reach. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); + +const INDEX_HTML = ` + + +
+
+
+ + +`; + +function evalInWindow(dom, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), dom.getInternalVMContext(), { filename: file }); +} + +function row(over = {}) { + return { path: '/work/a.txt', state: 'present', openable: true, tools: ['Write'], count: 1, sources: ['session'], ...over }; +} + +function result(over = {}) { + return { + ok: true, + files: [row()], + unresolved: [], + omitted: 0, + coverage: { transcripts: 1, subagents: 0, malformedLines: 0, truncated: false }, + ...over, + }; +} + +function setupDom({ touchedImpl, readImpl } = {}) { + const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const calls = { touched: [], readFile: [], viewerOpen: [] }; + + window.api = { + onMcpOpenDiff: () => {}, + onMcpOpenFile: () => {}, + onMcpCloseAllDiffs: () => {}, + onMcpCloseTab: () => {}, + mcpDiffResponse: () => {}, + sessionTouchedFiles: (sessionId) => { + calls.touched.push(sessionId); + return Promise.resolve((touchedImpl || (() => result()))(sessionId)); + }, + readFileForPanel: (filePath) => { + calls.readFile.push(filePath); + return Promise.resolve((readImpl || (() => ({ ok: true, content: 'file body' })))(filePath)); + }, + }; + window.confirm = () => true; + window.loadCodeMirrorBundle = () => Promise.resolve(); + Object.defineProperty(window, 'ViewerPanel', { + value: function ViewerPanelStub() { + return { + open(label, filePath, content) { calls.viewerOpen.push({ label, filePath, content }); }, + destroy() {}, + hasUnsavedEdits: () => false, + snapshot: () => null, + }; + }, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + + for (const file of ['splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'header-controls.js', 'file-panel.js', 'touched-files-view.js']) { + evalInWindow(dom, path.join(PUBLIC_DIR, file)); + } + window.initFilePanel(); + + const ctx = dom.getInternalVMContext(); + return { + window, + document: window.document, + calls, + stateOf: (sessionId) => vm.runInContext('filePanelState', ctx).get(sessionId), + destroy: () => window.close(), + }; +} + +function flush() { + let p = Promise.resolve(); + for (let i = 0; i < 12; i++) p = p.then(() => Promise.resolve()); + return p; +} + +async function openTab(ctx, sessionId = 's1') { + ctx.window.switchPanel(sessionId); + ctx.document.getElementById('touched-toggle-btn').click(); + await flush(); +} + +function rows(ctx) { + return [...ctx.document.querySelectorAll('.touched-file-row')]; +} + +function clickRow(ctx, filePath) { + const el = rows(ctx).find((r) => r.dataset.path === filePath); + assert.ok(el, `a row for ${filePath}`); + el.dispatchEvent(new ctx.window.Event('click', { bubbles: true })); +} + +test('the header carries a Touched toggle that opens the tab for the session and lists its files', async () => { + const ctx = setupDom({ + touchedImpl: () => result({ + files: [ + row({ path: '/work/a.txt', tools: ['Edit', 'Write'], count: 3, sources: ['session', 'subagent reviewer (abc1234)'] }), + row({ path: '/work/gone.txt', state: 'gone', openable: false }), + ], + }), + }); + try { + const btn = ctx.document.getElementById('touched-toggle-btn'); + assert.ok(btn, 'the toggle exists'); + assert.equal(btn.getAttribute('aria-pressed'), 'false'); + await openTab(ctx); + assert.deepEqual(ctx.calls.touched, ['s1']); + assert.equal(btn.getAttribute('aria-pressed'), 'true'); + assert.equal(ctx.stateOf('s1').currentTab.type, 'touched'); + assert.equal(rows(ctx).length, 2); + const first = rows(ctx)[0]; + assert.match(first.textContent, /\/work\/a\.txt/); + assert.match(first.textContent, /Edit, Write/); + assert.match(first.textContent, /3/); + assert.match(first.textContent, /subagent reviewer \(abc1234\)/); + assert.equal(ctx.document.getElementById('file-panel-touched').style.display, 'flex'); + } finally { ctx.destroy(); } +}); + +test('the tab states its coverage while loading, when listed, and when the listing fails', async () => { + let release; + const gate = new Promise((r) => { release = r; }); + const ctx = setupDom({ touchedImpl: () => gate.then(() => result()) }); + try { + ctx.window.switchPanel('s1'); + ctx.document.getElementById('touched-toggle-btn').click(); + const note = () => ctx.document.getElementById('touched-coverage'); + assert.ok(note(), 'the coverage note exists'); + const readNote = () => note().textContent; + const pinned = (text) => { + assert.match(text, /file tools/); + assert.match(text, /Edit, Write, MultiEdit/); + assert.match(text, /Bash/); + assert.match(text, /not the complete set/i); + }; + pinned(readNote()); + assert.notEqual(note().style.display, 'none'); + release(); + await flush(); + pinned(readNote()); + assert.equal(rows(ctx).length, 1); + } finally { ctx.destroy(); } + + const failing = setupDom({ touchedImpl: () => ({ ok: false, error: 'nope', reason: 'no-transcript' }) }); + try { + await openTab(failing); + assert.match(failing.document.getElementById('touched-coverage').textContent, /not the complete set/i); + assert.match(failing.document.getElementById('touched-summary').textContent, /nope/); + } finally { failing.destroy(); } +}); + +test('an empty listing says the file tools touched nothing, not that nothing changed', async () => { + const ctx = setupDom({ touchedImpl: () => result({ files: [] }) }); + try { + await openTab(ctx); + const summary = ctx.document.getElementById('touched-summary').textContent; + assert.match(summary, /No files/i); + assert.match(summary, /file tools/); + assert.doesNotMatch(summary, /unchanged|nothing changed/i); + } finally { ctx.destroy(); } +}); + +test('a present file opens through readFileForPanel and the file viewer', async () => { + const ctx = setupDom({ readImpl: () => ({ ok: true, content: 'hello' }) }); + try { + await openTab(ctx); + clickRow(ctx, '/work/a.txt'); + await flush(); + assert.deepEqual(ctx.calls.readFile, ['/work/a.txt']); + assert.equal(ctx.stateOf('s1').currentTab.type, 'file'); + assert.deepEqual(ctx.calls.viewerOpen.map((o) => [o.filePath, o.content]), [['/work/a.txt', 'hello']]); + } finally { ctx.destroy(); } +}); + +test('a gone, refused, unreadable or non-file row says so and a click reads nothing', async () => { + const ctx = setupDom({ + touchedImpl: () => result({ + files: [ + row({ path: '/work/gone.txt', state: 'gone', openable: false }), + row({ path: '/work/.ssh/id_rsa', state: 'refused', openable: false }), + row({ path: '/work/locked.txt', state: 'unreadable', openable: false }), + row({ path: '/work/dir', state: 'not-file', openable: false }), + ], + }), + }); + try { + await openTab(ctx); + const labels = Object.fromEntries(rows(ctx).map((r) => [r.dataset.path, r.querySelector('.touched-file-state').textContent])); + assert.deepEqual(labels, { + '/work/gone.txt': 'gone', + '/work/.ssh/id_rsa': 'refused', + '/work/locked.txt': 'unreadable', + '/work/dir': 'not a file', + }); + assert.match(rows(ctx)[0].title, /no longer exists/i); + for (const r of rows(ctx)) { + assert.equal(r.classList.contains('touched-openable'), false); + r.dispatchEvent(new ctx.window.Event('click', { bubbles: true })); + } + await flush(); + assert.deepEqual(ctx.calls.readFile, []); + assert.equal(ctx.stateOf('s1').currentTab.type, 'touched'); + } finally { ctx.destroy(); } +}); + +test('a row the main process did not mark openable is not opened even if its state reads present', async () => { + const ctx = setupDom({ touchedImpl: () => result({ files: [row({ state: 'present', openable: false })] }) }); + try { + await openTab(ctx); + clickRow(ctx, '/work/a.txt'); + await flush(); + assert.deepEqual(ctx.calls.readFile, []); + } finally { ctx.destroy(); } +}); + +test('a row whose state is not present is not opened even when flagged openable', async () => { + const ctx = setupDom({ touchedImpl: () => result({ files: [row({ state: 'gone', openable: true })] }) }); + try { + await openTab(ctx); + clickRow(ctx, '/work/a.txt'); + await flush(); + assert.deepEqual(ctx.calls.readFile, []); + } finally { ctx.destroy(); } +}); + +test('a file read that finishes after the tab was replaced does not take the panel back', async () => { + let release; + const gate = new Promise((r) => { release = r; }); + const ctx = setupDom({ readImpl: () => gate.then(() => ({ ok: true, content: 'late' })) }); + try { + await openTab(ctx); + clickRow(ctx, '/work/a.txt'); + ctx.window.openFileTab('s1', { filePath: '/work/other.txt', content: 'x' }); + release(); + await flush(); + assert.equal(ctx.stateOf('s1').currentTab.filePath, '/work/other.txt'); + assert.deepEqual(ctx.calls.viewerOpen.map((o) => o.filePath), ['/work/other.txt']); + } finally { ctx.destroy(); } +}); + +test('opening the tab hides whatever the panel showed before', async () => { + const ctx = setupDom(); + try { + ctx.window.switchPanel('s1'); + for (const id of ['file-panel-viewer', 'file-panel-diff', 'file-panel-changes']) { + ctx.document.getElementById(id).style.display = 'flex'; + } + ctx.document.getElementById('touched-toggle-btn').click(); + await flush(); + for (const id of ['file-panel-viewer', 'file-panel-diff', 'file-panel-changes']) { + assert.equal(ctx.document.getElementById(id).style.display, 'none', id); + } + assert.equal(ctx.document.getElementById('file-panel-touched').style.display, 'flex'); + } finally { ctx.destroy(); } +}); + +test('unresolved paths are listed apart, with their reason, and cannot be opened', async () => { + const ctx = setupDom({ + touchedImpl: () => result({ + files: [], + unresolved: [{ raw: 'rel/x.txt', reason: 'relative-no-cwd', tools: ['Write'], count: 2, sources: ['session'] }], + }), + }); + try { + await openTab(ctx); + const un = [...ctx.document.querySelectorAll('.touched-unresolved-row')]; + assert.equal(un.length, 1); + assert.match(un[0].textContent, /rel\/x\.txt/); + assert.match(un[0].textContent, /working directory/i); + assert.equal(un[0].dataset.path, undefined); + un[0].dispatchEvent(new ctx.window.Event('click', { bubbles: true })); + await flush(); + assert.deepEqual(ctx.calls.readFile, []); + } finally { ctx.destroy(); } +}); + +test('a refused read stays on the tab and shows the reason', async () => { + const ctx = setupDom({ readImpl: () => ({ ok: false, error: 'access to sensitive path denied' }) }); + try { + await openTab(ctx); + clickRow(ctx, '/work/a.txt'); + await flush(); + assert.equal(ctx.stateOf('s1').currentTab.type, 'touched'); + assert.match(ctx.document.getElementById('touched-summary').textContent, /access to sensitive path denied/); + assert.deepEqual(ctx.calls.viewerOpen, []); + } finally { ctx.destroy(); } +}); + +test('an omitted count, malformed lines and a truncated read are each reported', async () => { + const ctx = setupDom({ + touchedImpl: () => result({ + omitted: 7, + coverage: { transcripts: 3, subagents: 2, malformedLines: 4, skippedLines: 2, truncated: true }, + }), + }); + try { + await openTab(ctx); + const text = ctx.document.getElementById('touched-summary').textContent; + assert.match(text, /7 more/); + assert.match(text, /2 subagents/); + assert.match(text, /4 unreadable lines/); + assert.match(text, /2 oversized lines/); + assert.match(text, /read only part/i); + } finally { ctx.destroy(); } +}); + +test('a path that looks like markup is shown as text', async () => { + const hostile = '/work/.txt'; + const ctx = setupDom({ touchedImpl: () => result({ files: [row({ path: hostile })], unresolved: [{ raw: 'raw', reason: 'invalid', tools: ['Edit'], count: 1, sources: ['x'] }] }) }); + try { + await openTab(ctx); + assert.equal(ctx.document.querySelector('#touched-list img'), null); + assert.equal(ctx.document.querySelector('#touched-list b'), null); + assert.equal(ctx.document.querySelector('#touched-list i'), null); + assert.ok(rows(ctx)[0].textContent.includes(hostile)); + } finally { ctx.destroy(); } +}); + +test('a response that arrives after the tab was replaced is dropped', async () => { + let release; + const gate = new Promise((r) => { release = r; }); + const ctx = setupDom({ touchedImpl: () => gate.then(() => result()) }); + try { + ctx.window.switchPanel('s1'); + ctx.document.getElementById('touched-toggle-btn').click(); + ctx.window.openFileTab('s1', { filePath: '/work/other.txt', content: 'x' }); + release(); + await flush(); + assert.equal(ctx.stateOf('s1').currentTab.type, 'file'); + assert.equal(rows(ctx).length, 0); + } finally { ctx.destroy(); } +}); + +test('the toggle closes the tab, and the refresh button asks again', async () => { + const ctx = setupDom(); + try { + await openTab(ctx); + ctx.document.getElementById('touched-refresh-btn').click(); + await flush(); + assert.deepEqual(ctx.calls.touched, ['s1', 's1']); + const btn = ctx.document.getElementById('touched-toggle-btn'); + btn.click(); + await flush(); + assert.equal(ctx.stateOf('s1').currentTab, null); + assert.equal(btn.getAttribute('aria-pressed'), 'false'); + assert.equal(ctx.document.getElementById('file-panel-touched').style.display, 'none'); + } finally { ctx.destroy(); } +}); + +test('the touched toggle sits after Changes and before Stop in the header', () => { + const ctx = setupDom(); + try { + const ids = [...ctx.document.getElementById('terminal-header-controls').children].map((e) => e.id); + assert.deepEqual(ids, ['ide-emulation-indicator', 'changes-toggle-btn', 'touched-toggle-btn', 'terminal-stop-btn']); + } finally { ctx.destroy(); } +}); diff --git a/test/header-controls.test.js b/test/header-controls.test.js index ed488b57..3da4207c 100644 --- a/test/header-controls.test.js +++ b/test/header-controls.test.js @@ -41,6 +41,7 @@ test('the row is declared once: indicators, then panel toggles, then Stop last', ['ide-emulation-indicator', 'indicator'], ['panel-terminal-toggle-btn', 'toggle'], ['changes-toggle-btn', 'toggle'], + ['touched-toggle-btn', 'toggle'], ['terminal-stop-btn', 'action'], ]); const ranks = HEADER_CONTROLS.map((c) => KIND_RANK[c.kind]); @@ -64,8 +65,8 @@ test('index.html carries its static controls in the declared order, marked with test('placeHeaderControl puts each control at its declared place whatever the insertion order', () => { const orders = [ - ['terminal-stop-btn', 'changes-toggle-btn', 'panel-terminal-toggle-btn', 'ide-emulation-indicator', 'terminal-header-sandbox'], - ['changes-toggle-btn', 'terminal-stop-btn', 'ide-emulation-indicator', 'panel-terminal-toggle-btn', 'terminal-header-sandbox'], + ['terminal-stop-btn', 'touched-toggle-btn', 'changes-toggle-btn', 'panel-terminal-toggle-btn', 'ide-emulation-indicator', 'terminal-header-sandbox'], + ['touched-toggle-btn', 'changes-toggle-btn', 'terminal-stop-btn', 'ide-emulation-indicator', 'panel-terminal-toggle-btn', 'terminal-header-sandbox'], HEADER_CONTROLS.map((c) => c.id), ]; for (const order of orders) { @@ -112,7 +113,7 @@ test('a header toggle is an icon button with a tooltip, and shows its on state', assert.throws(() => createHeaderToggle({ id: 'terminal-stop-btn', label: 'x', title: 'x', icon: 'shell', onClick() {} }, doc), /not a header toggle/); - assert.deepEqual(Object.keys(HEADER_TOGGLE_ICONS).sort(), ['changes', 'shell']); + assert.deepEqual(Object.keys(HEADER_TOGGLE_ICONS).sort(), ['changes', 'shell', 'touched']); }); test('the live row, built by the modules in their start-up order, reads in the declared order', () => { @@ -120,9 +121,9 @@ test('the live row, built by the modules in their start-up order, reads in the d try { const doc = ctx.window.document; const present = HEADER_CONTROLS.map((c) => c.id).filter((id) => doc.getElementById(id)); - assert.deepEqual(present, ['ide-emulation-indicator', 'panel-terminal-toggle-btn', 'changes-toggle-btn', 'terminal-stop-btn']); + assert.deepEqual(present, ['ide-emulation-indicator', 'panel-terminal-toggle-btn', 'changes-toggle-btn', 'touched-toggle-btn', 'terminal-stop-btn']); assert.deepEqual(rowIds(doc), present); - for (const id of ['panel-terminal-toggle-btn', 'changes-toggle-btn']) { + for (const id of ['panel-terminal-toggle-btn', 'changes-toggle-btn', 'touched-toggle-btn']) { assert.equal(doc.getElementById(id).className, 'icon-btn', `#${id}`); } } finally { ctx.destroy(); } diff --git a/test/session-touched-files.test.js b/test/session-touched-files.test.js new file mode 100644 index 00000000..b7595bba --- /dev/null +++ b/test/session-touched-files.test.js @@ -0,0 +1,638 @@ +'use strict'; + +// The files a session's file tools touched — see .ai/contexts/touched-files.md. +// The transcript is attacker-influenced data (a sandboxed session writes its +// own), so every case below that matters is about what the listing refuses to +// do with a path, not about what it finds. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + TOUCH_TOOLS, + extractTouches, + resolveTouchedPath, + collectSessionTouchedFiles, +} = require('../session-touched-files'); + +function assistantLine(...blocks) { + return JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: blocks } }); +} + +function toolUse(name, input) { + return { type: 'tool_use', id: 'toolu_' + name, name, input }; +} + +// --- extractTouches ------------------------------------------------------ + +test('extractTouches reads the target of Edit, Write, MultiEdit and NotebookEdit calls', () => { + const line = assistantLine( + toolUse('Edit', { file_path: '/a/edit.js', old_string: 'x', new_string: 'y' }), + toolUse('Write', { file_path: '/a/write.js', content: 'z' }), + toolUse('MultiEdit', { file_path: '/a/multi.js', edits: [] }), + toolUse('NotebookEdit', { notebook_path: '/a/nb.ipynb', new_source: 's' }), + ); + const { touches, malformed } = extractTouches(line); + assert.equal(malformed, false); + assert.deepEqual(touches, [ + { tool: 'Edit', path: '/a/edit.js' }, + { tool: 'Write', path: '/a/write.js' }, + { tool: 'MultiEdit', path: '/a/multi.js' }, + { tool: 'NotebookEdit', path: '/a/nb.ipynb' }, + ]); + assert.deepEqual([...TOUCH_TOOLS].sort(), ['Edit', 'MultiEdit', 'NotebookEdit', 'Write']); +}); + +test('extractTouches ignores a tool that only reads, a shell call and a user turn', () => { + for (const line of [ + assistantLine(toolUse('Read', { file_path: '/a/read.js' })), + assistantLine(toolUse('Bash', { command: 'sed -i s/a/b/ /a/shell.js' })), + assistantLine(toolUse('Grep', { pattern: 'x', path: '/a' })), + JSON.stringify({ type: 'user', message: { role: 'user', content: [toolUse('Write', { file_path: '/a/forged.js' })] } }), + assistantLine({ type: 'text', text: 'I edited /a/prose.js with Write' }), + ]) { + assert.deepEqual(extractTouches(line), { touches: [], malformed: false }); + } +}); + +test('extractTouches skips a call whose path is not a non-empty string', () => { + const line = assistantLine( + toolUse('Write', { file_path: 42 }), + toolUse('Write', { file_path: '' }), + toolUse('Edit', {}), + toolUse('Edit'), + toolUse('NotebookEdit', { file_path: '/a/wrong-key.ipynb' }), + toolUse('Write', { file_path: '/a/ok.js' }), + ); + assert.deepEqual(extractTouches(line).touches, [{ tool: 'Write', path: '/a/ok.js' }]); +}); + +test('extractTouches reports a line that names a touch tool but is not JSON, and not any other garbage', () => { + assert.equal(extractTouches('{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","inp').malformed, true); + assert.deepEqual(extractTouches('not json at all'), { touches: [], malformed: false }); + assert.deepEqual(extractTouches(''), { touches: [], malformed: false }); + assert.deepEqual(extractTouches('{"type":"assistant","message":null}'), { touches: [], malformed: false }); +}); + +// --- resolveTouchedPath -------------------------------------------------- + +test('resolveTouchedPath keeps an absolute path, normalised', () => { + const abs = path.resolve(os.tmpdir(), 'proj', 'sub', '..', 'file.txt'); + assert.deepEqual(resolveTouchedPath(path.join(os.tmpdir(), 'proj', 'sub', '..', 'file.txt'), { cwd: null }), { path: abs }); +}); + +test('resolveTouchedPath resolves a relative path against a verified cwd only', () => { + const cwd = path.resolve(os.tmpdir(), 'proj'); + assert.deepEqual(resolveTouchedPath(path.join('src', 'a.js'), { cwd }), { path: path.join(cwd, 'src', 'a.js') }); + const unresolved = resolveTouchedPath(path.join('src', 'a.js'), { cwd: null }); + assert.equal(unresolved.path, undefined); + assert.equal(unresolved.unresolved, 'relative-no-cwd'); +}); + +test('resolveTouchedPath refuses a path the OS would treat as a network, device or drive-relative target', () => { + const win = { pathOps: path.win32 }; + for (const [raw, reason] of [ + ['\\\\server\\share\\x.txt', 'unsupported-form'], + ['//server/share/x.txt', 'unsupported-form'], + ['\\\\?\\C:\\x.txt', 'unsupported-form'], + ['\\\\.\\C:\\x.txt', 'unsupported-form'], + ['\\\\?\\UNC\\server\\share\\x.txt', 'unsupported-form'], + ['/rooted/without/drive.txt', 'rooted-no-drive'], + ['C:drive-relative.txt', 'drive-relative'], + ]) { + const r = resolveTouchedPath(raw, { ...win, cwd: 'C:\\proj' }); + assert.equal(r.path, undefined, raw); + assert.equal(r.unresolved, reason, raw); + } + assert.deepEqual(resolveTouchedPath('C:\\a\\..\\b\\x.txt', { ...win, cwd: null }), { path: 'C:\\b\\x.txt' }); +}); + +test('resolveTouchedPath refuses control characters, a home shortcut and an oversized path', () => { + const cwd = path.resolve(os.tmpdir(), 'proj'); + for (const raw of [ + path.join(cwd, 'a\0b'), + path.join(cwd, 'a\nb'), + '~/x.txt', + path.join(cwd, 'x'.repeat(5000)), + ]) { + const r = resolveTouchedPath(raw, { cwd }); + assert.equal(r.path, undefined, JSON.stringify(raw.slice(0, 40))); + assert.equal(typeof r.unresolved, 'string'); + } +}); + +// --- collectSessionTouchedFiles ----------------------------------------- + +function makeWorld() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'touched-')); + const folder = path.join(root, 'projects', '-proj'); + const work = path.join(root, 'work'); + fs.mkdirSync(folder, { recursive: true }); + fs.mkdirSync(work, { recursive: true }); + const write = (rel, content) => { + const p = path.join(root, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + return p; + }; + return { root, folder, work, write, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; +} + +function lines(...ls) { + return ls.join('\n') + '\n'; +} + +function deps(world, over = {}) { + return { + folderPath: world.folder, + sessionId: 'S1', + cwdOf: () => null, + isSensitive: async () => false, + ...over, + }; +} + +function byPath(result) { + return Object.fromEntries(result.files.map((f) => [f.path, f])); +} + +test('a file written outside any repository is listed, present on disk', async () => { + const w = makeWorld(); + try { + const outside = w.write('work/notes.txt', 'hello'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: outside, content: 'hello' })))); + const result = await collectSessionTouchedFiles(deps(w)); + assert.equal(result.ok, true); + assert.equal(result.files.length, 1); + assert.equal(result.files[0].path, path.resolve(outside)); + assert.equal(result.files[0].state, 'present'); + assert.equal(result.files[0].openable, true); + assert.deepEqual(result.files[0].tools, ['Write']); + assert.equal(result.files[0].count, 1); + } finally { w.cleanup(); } +}); + +test('a subagent transcript is walked, and its files carry the agent that touched them', async () => { + const w = makeWorld(); + try { + const mine = w.write('work/mine.txt', 'a'); + const theirs = w.write('work/theirs.txt', 'b'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Edit', { file_path: mine })))); + w.write('projects/-proj/S1/subagents/agent-abc123.jsonl', lines(assistantLine(toolUse('Write', { file_path: theirs })))); + w.write('projects/-proj/S1/subagents/agent-abc123.meta.json', JSON.stringify({ agentType: 'reviewer' })); + const result = await collectSessionTouchedFiles(deps(w, { labelOf: (entry) => (entry.parentSessionId ? 'subagent ' + entry.sessionId : 'session') })); + const files = byPath(result); + assert.deepEqual(files[path.resolve(mine)].sources, ['session']); + assert.deepEqual(files[path.resolve(theirs)].sources, ['subagent agent-abc123']); + assert.equal(result.coverage.transcripts, 2); + assert.equal(result.coverage.subagents, 1); + } finally { w.cleanup(); } +}); + +test('a legacy subagent layout (jsonl directly under the session directory) is walked too', async () => { + const w = makeWorld(); + try { + const theirs = w.write('work/legacy.txt', 'b'); + w.write('projects/-proj/S1.jsonl', lines()); + w.write('projects/-proj/S1/agent-old.jsonl', lines(assistantLine(toolUse('Write', { file_path: theirs })))); + const result = await collectSessionTouchedFiles(deps(w)); + assert.equal(result.files.length, 1); + assert.equal(result.coverage.subagents, 1); + } finally { w.cleanup(); } +}); + +test("another session's transcript in the same folder contributes nothing", async () => { + const w = makeWorld(); + try { + const other = w.write('work/other.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines()); + w.write('projects/-proj/S2.jsonl', lines(assistantLine(toolUse('Write', { file_path: other })))); + w.write('projects/-proj/S2/subagents/agent-z.jsonl', lines(assistantLine(toolUse('Write', { file_path: other })))); + const result = await collectSessionTouchedFiles(deps(w)); + assert.deepEqual(result.files, []); + assert.equal(result.coverage.transcripts, 1); + } finally { w.cleanup(); } +}); + +test('the same file touched by the session and a subagent is one row with merged tools, count and sources', async () => { + const w = makeWorld(); + try { + const shared = w.write('work/shared.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines( + assistantLine(toolUse('Write', { file_path: shared })), + assistantLine(toolUse('Edit', { file_path: shared })), + )); + w.write('projects/-proj/S1/subagents/agent-a.jsonl', lines(assistantLine(toolUse('MultiEdit', { file_path: shared })))); + const result = await collectSessionTouchedFiles(deps(w, { labelOf: (e) => (e.parentSessionId ? 'sub' : 'main') })); + assert.equal(result.files.length, 1); + const [row] = result.files; + assert.deepEqual(row.tools, ['Edit', 'MultiEdit', 'Write']); + assert.equal(row.count, 3); + assert.deepEqual(row.sources, ['main', 'sub']); + } finally { w.cleanup(); } +}); + +test('a listed file that no longer exists is reported gone, not dropped and not an error', async () => { + const w = makeWorld(); + try { + const missing = path.join(w.work, 'deleted-later.txt'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: missing })))); + const result = await collectSessionTouchedFiles(deps(w)); + assert.equal(result.files[0].state, 'gone'); + assert.equal(result.files[0].openable, false); + } finally { w.cleanup(); } +}); + +test('a directory and an unreadable path each get their own state', async () => { + const w = makeWorld(); + try { + const dir = path.join(w.work, 'adir'); + fs.mkdirSync(dir); + const locked = path.join(w.work, 'locked.txt'); + w.write('projects/-proj/S1.jsonl', lines( + assistantLine(toolUse('Write', { file_path: dir })), + assistantLine(toolUse('Write', { file_path: locked })), + )); + const result = await collectSessionTouchedFiles(deps(w, { + statPath: async (p) => { + if (p === path.resolve(locked)) throw Object.assign(new Error('denied'), { code: 'EACCES' }); + return fs.promises.stat(p); + }, + })); + const files = byPath(result); + assert.equal(files[path.resolve(dir)].state, 'not-file'); + assert.equal(files[path.resolve(dir)].openable, false); + assert.equal(files[path.resolve(locked)].state, 'unreadable'); + assert.equal(files[path.resolve(locked)].openable, false); + } finally { w.cleanup(); } +}); + +test('a path in a credential location is listed as refused and is never stat-ed', async () => { + const w = makeWorld(); + try { + const secret = path.join(w.work, '.ssh', 'id_rsa'); + const plain = w.write('work/plain.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines( + assistantLine(toolUse('Write', { file_path: secret })), + assistantLine(toolUse('Write', { file_path: plain })), + )); + const statted = []; + const result = await collectSessionTouchedFiles(deps(w, { + isSensitive: async (p) => /[/\\]\.ssh[/\\]/.test(p), + statPath: async (p) => { statted.push(p); return fs.promises.stat(p); }, + })); + const files = byPath(result); + assert.equal(files[path.resolve(secret)].state, 'refused'); + assert.equal(files[path.resolve(secret)].openable, false); + assert.deepEqual(statted, [path.resolve(plain)]); + } finally { w.cleanup(); } +}); + +test('a sensitivity check that fails leaves the row refused rather than stat-ed', async () => { + const w = makeWorld(); + try { + const p = w.write('work/a.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: p })))); + const statted = []; + const result = await collectSessionTouchedFiles(deps(w, { + isSensitive: async () => { throw new Error('boom'); }, + statPath: async (q) => { statted.push(q); return fs.promises.stat(q); }, + })); + assert.equal(result.files[0].state, 'refused'); + assert.deepEqual(statted, []); + } finally { w.cleanup(); } +}); + +test('a relative path resolves against the verified session cwd, and stays unresolved without one', async () => { + const w = makeWorld(); + try { + w.write('work/rel.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: 'rel.txt' })))); + w.write('projects/-proj/S1/subagents/agent-a.jsonl', lines(assistantLine(toolUse('Write', { file_path: 'sub-rel.txt' })))); + + const verified = await collectSessionTouchedFiles(deps(w, { + cwdOf: (entry) => (entry.parentSessionId ? null : w.work), + })); + assert.equal(verified.files.length, 1); + assert.equal(verified.files[0].path, path.join(w.work, 'rel.txt')); + assert.equal(verified.files[0].state, 'present'); + assert.equal(verified.unresolved.length, 1); + assert.equal(verified.unresolved[0].raw, 'sub-rel.txt'); + assert.equal(verified.unresolved[0].reason, 'relative-no-cwd'); + + const none = await collectSessionTouchedFiles(deps(w)); + assert.deepEqual(none.files, []); + assert.deepEqual(none.unresolved.map((u) => u.raw).sort(), ['rel.txt', 'sub-rel.txt']); + for (const u of none.unresolved) assert.equal('path' in u, false, 'an unresolved row carries nothing openable'); + } finally { w.cleanup(); } +}); + +test('an unresolvable path is listed once with its tools and never stat-ed', async () => { + const w = makeWorld(); + try { + w.write('projects/-proj/S1.jsonl', lines( + assistantLine(toolUse('Write', { file_path: '\\\\attacker\\share\\x.txt' })), + assistantLine(toolUse('Edit', { file_path: '\\\\attacker\\share\\x.txt' })), + )); + const statted = []; + const result = await collectSessionTouchedFiles(deps(w, { + pathOps: path.win32, + statPath: async (p) => { statted.push(p); return fs.promises.stat(p); }, + })); + assert.deepEqual(result.files, []); + assert.equal(result.unresolved.length, 1); + assert.deepEqual(result.unresolved[0].tools, ['Edit', 'Write']); + assert.equal(result.unresolved[0].count, 2); + assert.deepEqual(statted, []); + } finally { w.cleanup(); } +}); + +test('a malformed line is counted and the lines around it still read', async () => { + const w = makeWorld(); + try { + const a = w.write('work/a.txt', 'x'); + const b = w.write('work/b.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines( + assistantLine(toolUse('Write', { file_path: a })), + '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","inp', + 'garbage', + assistantLine(toolUse('Write', { file_path: b })), + )); + const result = await collectSessionTouchedFiles(deps(w)); + assert.equal(result.files.length, 2); + assert.equal(result.coverage.malformedLines, 1); + } finally { w.cleanup(); } +}); + +test('a session with no transcript on disk is an error the caller can name', async () => { + const w = makeWorld(); + try { + const result = await collectSessionTouchedFiles(deps(w, { sessionId: 'NOPE' })); + assert.equal(result.ok, false); + assert.equal(result.reason, 'no-transcript'); + } finally { w.cleanup(); } +}); + +test('more distinct files than the cap are counted as omitted, not silently dropped', async () => { + const w = makeWorld(); + try { + const blocks = []; + for (let i = 0; i < 7; i++) blocks.push(toolUse('Write', { file_path: path.join(w.work, `f${i}.txt`) })); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(...blocks))); + const result = await collectSessionTouchedFiles(deps(w, { maxFiles: 5 })); + assert.equal(result.files.length, 5); + assert.equal(result.omitted, 2); + } finally { w.cleanup(); } +}); + +test('a transcript read past the byte budget is flagged truncated and keeps what it read', async () => { + const w = makeWorld(); + try { + const first = w.write('work/first.txt', 'x'); + const second = w.write('work/second.txt', 'x'); + const l1 = assistantLine(toolUse('Write', { file_path: first })); + const l2 = assistantLine(toolUse('Write', { file_path: second })); + w.write('projects/-proj/S1.jsonl', lines(l1, l2)); + const result = await collectSessionTouchedFiles(deps(w, { maxBytes: Buffer.byteLength(l1) + 1 })); + assert.equal(result.coverage.truncated, true); + assert.deepEqual(result.files.map((f) => f.path), [path.resolve(first)]); + + const full = await collectSessionTouchedFiles(deps(w)); + assert.equal(full.coverage.truncated, false); + assert.equal(full.files.length, 2); + } finally { w.cleanup(); } +}); + +test('stat runs on at most a few rows at once', async () => { + const w = makeWorld(); + try { + const blocks = []; + for (let i = 0; i < 40; i++) blocks.push(toolUse('Write', { file_path: path.join(w.work, `f${i}.txt`) })); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(...blocks))); + let inFlight = 0; + let peak = 0; + await collectSessionTouchedFiles(deps(w, { + statPath: async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 2)); + inFlight--; + throw Object.assign(new Error('nope'), { code: 'ENOENT' }); + }, + })); + assert.ok(peak > 1, 'it does overlap'); + assert.ok(peak <= 8, `peak ${peak}`); + } finally { w.cleanup(); } +}); + +test('a stat that never answers leaves the row unreadable instead of holding the listing', async () => { + const w = makeWorld(); + try { + const p = w.write('work/slow.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: p })))); + const result = await collectSessionTouchedFiles(deps(w, { + statPath: () => new Promise(() => {}), + statTimeoutMs: 20, + })); + assert.equal(result.files[0].state, 'unreadable'); + } finally { w.cleanup(); } +}); + +// --- listSessionTouchedFiles (the IPC's target resolution) ----------------- + +const { listSessionTouchedFiles } = require('../session-touched-files'); +const { encodeProjectPath } = require('../encode-project-path'); + +function listDeps(world, over = {}) { + return { + projectsDir: path.join(world.root, 'projects'), + getCachedFolder: () => '-proj', + isRemoteFolder: () => false, + isSensitive: async () => false, + ...over, + }; +} + +function cwdLine(cwd) { + return JSON.stringify({ type: 'user', cwd, message: { role: 'user', content: 'hi' } }); +} + +test('listSessionTouchedFiles refuses a subagent id, a bad id and a remote session before reading anything', async () => { + const w = makeWorld(); + try { + w.write('projects/-proj/S1.jsonl', lines()); + for (const id of ['sub:S1:abc', '', '..', 'a/b', 'S1\\..\\x', null, 42]) { + const r = await listSessionTouchedFiles(id, listDeps(w)); + assert.equal(r.ok, false, String(id)); + assert.equal(r.error, 'invalid session id', String(id)); + } + const remote = await listSessionTouchedFiles('S1', listDeps(w, { isRemoteFolder: () => true })); + assert.equal(remote.ok, false); + assert.equal(remote.reason, 'remote'); + } finally { w.cleanup(); } +}); + +test('listSessionTouchedFiles never follows a folder name that leaves the projects directory', async () => { + const w = makeWorld(); + try { + const outside = w.write('work/x.txt', 'x'); + w.write('elsewhere/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: outside })))); + for (const folder of ['../elsewhere', '..', '.', 'a/b', 'a\\b', '', null]) { + const r = await listSessionTouchedFiles('S1', listDeps(w, { getCachedFolder: () => folder })); + assert.equal(r.ok, false, String(folder)); + assert.equal(r.reason, 'no-transcript', String(folder)); + } + } finally { w.cleanup(); } +}); + +test('listSessionTouchedFiles resolves a relative path against a cwd that encodes back to its folder', async () => { + const w = makeWorld(); + try { + const folder = encodeProjectPath(w.work); + w.write('work/rel.txt', 'x'); + w.write(`projects/${folder}/S1.jsonl`, lines(cwdLine(w.work), assistantLine(toolUse('Write', { file_path: 'rel.txt' })))); + const r = await listSessionTouchedFiles('S1', listDeps(w, { getCachedFolder: () => folder })); + assert.equal(r.ok, true); + assert.deepEqual(r.files.map((f) => [f.path, f.state]), [[path.join(w.work, 'rel.txt'), 'present']]); + } finally { w.cleanup(); } +}); + +test('listSessionTouchedFiles does not trust a transcript cwd that does not encode back to its folder', async () => { + const w = makeWorld(); + try { + w.write('work/rel.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines(cwdLine(w.work), assistantLine(toolUse('Write', { file_path: 'rel.txt' })))); + const r = await listSessionTouchedFiles('S1', listDeps(w)); + assert.deepEqual(r.files, []); + assert.deepEqual(r.unresolved.map((u) => [u.raw, u.reason]), [['rel.txt', 'relative-no-cwd']]); + } finally { w.cleanup(); } +}); + +test('listSessionTouchedFiles gives a subagent its own cwd only when that cwd verifies too', async () => { + const w = makeWorld(); + try { + const folder = encodeProjectPath(w.work); + const elsewhere = path.join(w.root, 'worktree-elsewhere'); + fs.mkdirSync(elsewhere); + w.write('work/same.txt', 'x'); + w.write('worktree-elsewhere/wt.txt', 'x'); + w.write(`projects/${folder}/S1.jsonl`, lines()); + w.write(`projects/${folder}/S1/subagents/agent-a.jsonl`, lines(cwdLine(w.work), assistantLine(toolUse('Write', { file_path: 'same.txt' })))); + w.write(`projects/${folder}/S1/subagents/agent-b.jsonl`, lines(cwdLine(elsewhere), assistantLine(toolUse('Write', { file_path: 'wt.txt' })))); + const r = await listSessionTouchedFiles('S1', listDeps(w, { getCachedFolder: () => folder })); + assert.deepEqual(r.files.map((f) => f.path), [path.join(w.work, 'same.txt')]); + assert.deepEqual(r.unresolved.map((u) => u.raw), ['wt.txt']); + } finally { w.cleanup(); } +}); + +test('listSessionTouchedFiles names a subagent by its recorded type and a short id', async () => { + const w = makeWorld(); + try { + const theirs = w.write('work/theirs.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines()); + w.write('projects/-proj/S1/subagents/agent-abcdef0123.jsonl', lines(assistantLine(toolUse('Write', { file_path: theirs })))); + w.write('projects/-proj/S1/subagents/agent-abcdef0123.meta.json', JSON.stringify({ agentType: 'reviewer\nforged line' })); + const r = await listSessionTouchedFiles('S1', listDeps(w)); + assert.deepEqual(r.files[0].sources, ['subagent reviewer forged line (abcdef0)']); + } finally { w.cleanup(); } +}); + +// --- review follow-ups ------------------------------------------------------ + +test('a sensitivity check that never answers leaves the row unreadable instead of holding the listing', async () => { + const w = makeWorld(); + try { + const p = w.write('work/hang.txt', 'x'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(toolUse('Write', { file_path: p })))); + const result = await collectSessionTouchedFiles(deps(w, { + isSensitive: () => new Promise(() => {}), + statTimeoutMs: 20, + })); + assert.equal(result.files[0].state, 'unreadable'); + assert.equal(result.files[0].openable, false); + } finally { w.cleanup(); } +}); + +test('after a few timed-out checks no new file-system call is issued and the rest are unreadable', async () => { + const w = makeWorld(); + try { + const blocks = []; + for (let i = 0; i < 60; i++) blocks.push(toolUse('Write', { file_path: path.join(w.work, `f${i}.txt`) })); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(...blocks))); + let issued = 0; + const result = await collectSessionTouchedFiles(deps(w, { + isSensitive: () => { issued += 1; return new Promise(() => {}); }, + statTimeoutMs: 15, + maxTimedOutChecks: 3, + })); + assert.equal(result.files.length, 60); + assert.ok(result.files.every((f) => f.state === 'unreadable')); + assert.ok(issued <= 3 + 8, `issued ${issued}`); + assert.ok(issued >= 3, `issued ${issued}`); + } finally { w.cleanup(); } +}); + +test('a path with a bidi override, isolate, mark, zero-width or C1 character is not resolved', () => { + const cwd = path.resolve(os.tmpdir(), 'proj'); + const hostile = [ + '\u202E', '\u202A', '\u2066', '\u2069', '\u200E', '\u200F', '\u061C', '\u200B', '\u2060', '\uFEFF', '\u0085', '\u009F', '\u2028', '\u{E0041}', + ]; + for (const ch of hostile) { + const r = resolveTouchedPath(path.join(cwd, `report${ch}txt.exe`), { cwd }); + assert.equal(r.path, undefined, JSON.stringify(ch)); + assert.equal(r.unresolved, 'control-character', JSON.stringify(ch)); + } + assert.deepEqual(resolveTouchedPath(path.join(cwd, 'rapport-é-日本.txt'), { cwd }), { path: path.join(cwd, 'rapport-é-日本.txt') }); +}); + +test('an unresolved path shows its unsafe characters as visible escapes', async () => { + const w = makeWorld(); + try { + const hostile = path.join(w.work, 'report\u202Etxt.exe'); + w.write('projects/-proj/S1.jsonl', lines(assistantLine( + toolUse('Write', { file_path: hostile }), + toolUse('Write', { file_path: path.join(w.work, 'tag\u{E0041}x') }), + toolUse('Write', { file_path: path.join(w.work, 'tab\there') }), + ))); + const result = await collectSessionTouchedFiles(deps(w)); + assert.deepEqual(result.files, []); + const raws = result.unresolved.map((u) => u.raw); + assert.ok(raws.some((r) => r.endsWith('report\\u202Etxt.exe')), raws.join('|')); + assert.ok(raws.some((r) => r.endsWith('tag\\u{E0041}x')), raws.join('|')); + assert.ok(raws.some((r) => r.endsWith('tab\\u0009here')), raws.join('|')); + for (const r of raws) assert.doesNotMatch(r, /[\u0000-\u001f\u202e\u{e0041}]/u); + } finally { w.cleanup(); } +}); + +test('a tool-call line past the per-line bound is skipped and counted, the lines around it still read', async () => { + const w = makeWorld(); + try { + const a = w.write('work/a.txt', 'x'); + const b = w.write('work/b.txt', 'x'); + const big = assistantLine(toolUse('Write', { file_path: path.join(w.work, 'huge.txt'), content: 'z'.repeat(4 * 1024 * 1024 + 10) })); + w.write('projects/-proj/S1.jsonl', lines( + assistantLine(toolUse('Write', { file_path: a })), + big, + assistantLine(toolUse('Write', { file_path: b })), + )); + const result = await collectSessionTouchedFiles(deps(w)); + assert.deepEqual(result.files.map((f) => f.path).sort(), [path.resolve(a), path.resolve(b)].sort()); + assert.equal(result.coverage.skippedLines, 1); + } finally { w.cleanup(); } +}); + +test('the overflow past the cap is counted without keeping the overflowing paths', async () => { + const w = makeWorld(); + try { + const blocks = []; + for (let i = 0; i < 9; i++) blocks.push(toolUse('Write', { file_path: path.join(w.work, `f${i % 9}.txt`) })); + blocks.push(toolUse('Write', { file_path: path.join(w.work, 'f8.txt') })); + w.write('projects/-proj/S1.jsonl', lines(assistantLine(...blocks))); + const result = await collectSessionTouchedFiles(deps(w, { maxFiles: 5 })); + assert.equal(result.files.length, 5); + assert.equal(result.omitted, 5); + } finally { w.cleanup(); } +}); diff --git a/test/terminal-manager-harness.js b/test/terminal-manager-harness.js index 6ce0c641..e4ca3b1b 100644 --- a/test/terminal-manager-harness.js +++ b/test/terminal-manager-harness.js @@ -226,7 +226,7 @@ function setupTerminalDom(opts = {}) { const ctx = dom.getInternalVMContext(); const files = ['utils.js', 'shortcuts.js', 'subagent-timing.js', 'terminal-path-links.js', 'terminal-context-menu.js', 'terminal-manager.js', 'grid-view.js']; // Same order as index.html: header-controls.js, process-exit.js and file-panel.js first, the panel-shell pair last. - if (opts.filePanel) files.unshift('header-controls.js', 'process-exit.js', 'file-panel.js'); + if (opts.filePanel) files.unshift('header-controls.js', 'process-exit.js', 'file-panel.js', 'touched-files-view.js'); if (opts.filePanel) files.push('splitter.js', 'panel-terminal.js'); for (const file of files) { const fullPath = path.join(PUBLIC_DIR, file); diff --git a/test/touched-files-wiring.test.js b/test/touched-files-wiring.test.js new file mode 100644 index 00000000..e204b77a --- /dev/null +++ b/test/touched-files-wiring.test.js @@ -0,0 +1,47 @@ +// Reads the shipped sources as TEXT: it shows the glue between the touched-files +// listing and the rest of the app is still written down. Behaviour is in +// session-touched-files.test.js and dom-file-panel-touched.test.js. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const read = (rel) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + +test('main.js serves session-touched-files through the sensitive-path guard and the cached folder', () => { + const src = read('main.js'); + const at = src.indexOf("ipcMain.handle('session-touched-files'"); + assert.notEqual(at, -1, 'the handler is registered'); + const body = src.slice(at, src.indexOf('});', src.indexOf('listSessionTouchedFiles(', at)) + 3); + assert.match(body, /isSensitive:\s*isSensitivePathAsync/); + assert.match(body, /getCachedFolder/); + assert.match(body, /isRemoteFolder/); + assert.match(body, /projectsDir:\s*PROJECTS_DIR/); +}); + +test('preload.js exposes sessionTouchedFiles on the same channel and nothing that reads a path', () => { + const src = read('preload.js'); + assert.match(src, /sessionTouchedFiles:\s*\(sessionId\)\s*=>\s*ipcRenderer\.invoke\('session-touched-files',\s*sessionId\)/); +}); + +test('index.html loads the touched tab after the file panel it hooks into', () => { + const html = read('public/index.html'); + const panel = html.indexOf('src="file-panel.js"'); + const touched = html.indexOf('src="touched-files-view.js"'); + assert.ok(panel !== -1 && touched > panel); +}); + +test('the touched tab opens a file only through readFileForPanel', () => { + const src = read('public/touched-files-view.js'); + const apis = [...src.matchAll(/window\.api\.(\w+)/g)].map((m) => m[1]); + assert.deepEqual([...new Set(apis)].sort(), ['readFileForPanel', 'sessionTouchedFiles']); +}); + +test('a path is shown in its own bidi isolate, so an override in it cannot reorder the row', () => { + const css = read('public/style.css').replace(/\/\*[\s\S]*?\*\//g, ''); + const rule = /\.touched-file-path\s*\{([^}]*)\}/.exec(css); + assert.ok(rule, 'the rule exists'); + assert.match(rule[1], /unicode-bidi:\s*isolate/); +});