Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ An unknown remote target never falls back to local disk IO.
|---|---|
| `open-external` | Opens https:// URLs in OS browser |
| `open-main-log` | No arguments; opens the main-selected, validated application log path and returns `{ok, error?}`. |
| `open-path` | Resolves a non-empty string path, applies the extension allowlist when `viewer` is set, then `isSensitivePath`, before `shell.openPath`. Returns the OS error string (empty on success), or `undefined` on refusal. |
| `show-item-in-folder` | Requires a string absolute path, resolves it, checks raw Git segments, native-realpath Git segments, `isSensitivePath`, then regular-file status, in that order, before `shell.showItemInFolder`. Returns `{ok:true}` or `{ok:false, reason}` including IO failures. |
| `clipboard-write-text` | Main-process clipboard write (Wayland fix, PR #18) |
| `get-app-version` | From package.json |
| `whats-new-startup` / `whats-new-dismissed` | The What's new dialog: the `CHANGELOG.md` sections to show on startup (or `null`), and recording the running version as `lastSeenVersion` when it closes. Help → What's new sends `show-whats-new` with the running version's section. See `docs/changelog.md` |
Expand Down Expand Up @@ -507,14 +509,21 @@ adapter applies it.

### IPC path-guard inventory

Main applies the `open-path` extension allowlist only when the caller sends
`viewer`. It guards against a renderer bug, not a hostile renderer, which can
omit the flag. The terminal menu's unflagged call retains its existing behavior
(#1025 spec section 3).

Every handler that takes a renderer-supplied path or derives a spawn location from one, and what guards it as of this pass:

| IPC | Guard | Kind |
|---|---|---|
| `read-memory` / `save-memory` | `resolveAllowedMemoryPath` (read/write the returned path, not the caller's own re-resolved one) | disk-resolved allowlist |
| `get-memories` (the list, and the FTS bodies it indexes) | `acceptMdFile` (`scan-md-files.js`) applies `isSensitivePath` itself and the `isAllowedMemoryPath` predicate `get-memories` passes in; `scanMdFiles` is the directory-wide form of it, and the project-root `CLAUDE.md`/`GEMINI.md`/`agents.md` go through it by name | disk-resolved denylist + allowlist — a listed file is read twice more downstream, once by `read-memory` behind `resolveAllowedMemoryPath` and once by the FTS indexer behind nothing at all, so the guard belongs on the list. The denylist is not the caller's to choose: containment in an allowed project root says nothing about a cloned repo's own `.ssh`/`.env` |
| `open-main-log` | Main chooses electron-log's path; requires a string absolute path and `!isSensitivePath` before `shell.openPath`. | no renderer-supplied path; disk-resolved denylist |
| `open-path` / `read-file-for-panel` / `save-file-for-panel` / `watch-file` | Local: `isSensitivePath`. Remote panel reads resolve the session target and guard the literal and canonical host path. Saves refuse a resolved remote target and any path already opened through the remote panel route, even without renderer options. | disk-resolved denylist + main-side remote write refusal |
| `open-path` | Non-empty string, `path.resolve`, then, only with `opts.viewer`, an extension allowlist (`.png .jpg .jpeg .gif .webp .bmp .pdf`, case insensitive), then `isSensitivePath`, before `shell.openPath`. | caller-selected allowlist + disk-resolved denylist |
| `show-item-in-folder` | String and `path.isAbsolute`, `path.resolve`, raw `hasGitSegment`, `hasGitSegment(fs.realpathSync.native(resolved))`, `isSensitivePath`, then `fs.statSync(resolved).isFile()`, before `shell.showItemInFolder`. IO failures return `{ok:false, reason}`. | shape + raw/canonical Git metadata refusal + disk-resolved denylist + regular-file check |
| `read-file-for-panel` / `save-file-for-panel` / `watch-file` | Local: `isSensitivePath`. Remote panel reads resolve the session target and guard the literal and canonical host path. Saves refuse a resolved remote target and any path already opened through the remote panel route, even without renderer options. | disk-resolved denylist + main-side remote write refusal |
| `resolve-terminal-paths` | `resolveTerminalPaths` / `resolveTerminalPathTarget` (`src/main/terminals/terminal-path-target.js`, async, 8 checks in flight): the session cwd for a relative path, existence, then `isSensitivePath` on the resolved path, then regular-file-ness, `PANEL_FILE_MAX_BYTES` and a NUL-byte sniff | disk-resolved denylist, strictly narrower than `read-file-for-panel` — its whole input is untrusted terminal output, and its answer decides whether a path is even offered as a link. Existence is tested before the denylist because most candidates on a line of prose are not files; a path that exists still passes the denylist before anything else is decided. A remote session is refused outright, and so is one whose working directory cannot be resolved — otherwise an unresolvable remote would fall back to stat-ing the local disk. A panel shell (`panelFor`) resolves through its owning session |
| `check-delete-worktree` / `delete-worktree` / `worktree-status` | Shared `checkDeleteWorktree`: `WORKTREE_PATH_RE` (shape) + `isKnownProjectRoot` (disk-resolved exact match) | shape + disk-resolved allowlist |
| `delete-session-preview` / `delete-session` | `deleteSessionGuard` then `resolveRowDeletionTargets` (`delete-session-target.js`) | full live/background/unknown-state guard + disk-resolved containment |
Expand Down
39 changes: 38 additions & 1 deletion .ai/contexts/touched-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ listing *does* with it.
it.
- **Listing is not opening.** Only a row with `openable === true` and
`state === 'present'` has a click handler. It calls `readFileForPanel`
with `{editor: true}`; the main handler retains its sensitive-path,
with `{editor: true, image: true}` locally; the main handler retains its sensitive-path,
regular-file, size and binary guards. An unsuccessful read leaves the list
visible with the refusal. Gone, unreadable, refused and non-file rows keep
their existing state and cannot be opened. The absolute path is one main
Expand Down Expand Up @@ -579,3 +579,40 @@ real `marked` and `DOMPurify` builds; `window.marked` is set only by the
The tool entry point is the right-hand bar, using the shown terminal owner.
Changes and Touched park an unanswered Diff; its conditional button restores
the same editor. See [tool-bar.md](tool-bar.md).

## Images

Local Touched opens request { editor: true, image: true }; remote reads retain
their session ID and never request images. The main read preserves the existing
Git metadata, sensitive-path, regular-file and 2 MiB guards before sniffing bytes.
panel-image.js recognizes PNG, JPEG, GIF, WebP and BMP signatures, independently
of the extension. SVG stays editable text; ICO and other binary formats are refused.
GIF and WebP signatures become images only when the read buffer also contains
a NUL byte; NUL-free UTF-8 text beginning with those signatures stays editable.

openTouchedImage shares the Changes tab layout and Touched return list. It sets
image, editable=false and noDiff=true, watches the local file and re-reads with
the image flag on file-changed. Rendering uses an img.panel-image with a data:
source; it changes neither CSP nor ViewerPanel (#827). Images fit the pane and
click to toggle intrinsic size with scrolling. Format, Mode, Save and Reload
are hidden. A decode failure shows the same refusal as an unsupported format.
Errors from detached images are ignored so an earlier render cannot refuse the
currently displayed image. Refresh clears both earlier open errors and refusals.

Binary, encoding and size refusals show "This file can't be shown here." with
one action. PNG/JPG/JPEG/GIF/WebP/BMP/PDF extensions, case insensitive, offer
Open with system viewer via openPath(path, { viewer: true }); all others offer
Show in folder. SYSTEM_VIEWER_EXTENSIONS lives in the pure main module and a
classic-script copy in touched-files-view.js; panel-image.test.js compares them.
Main enforces the same viewer allowlist. Unflagged terminal openPath is unchanged.

show-item-in-folder validates string and absolute path before resolving, then
refuses raw and native-realpath Git segments, sensitive paths, and non-regular
files, in that order, before calling shell.showItemInFolder. The handler returns
{ ok: true } or { ok: false, reason }, including IO failures. Either action shows
a short inline error if refused, rejected, or unsuccessful; a successful retry
clears it. Other Touched read failures keep their existing message with no action.
Source-extracted IPC tests inject the sniff module and sensitivity guard; DOM
fixtures forward read options and record both system actions. Image DOM cases
live in test/dom-file-panel-images.test.js and share touched-panel-harness.js
with test/dom-file-panel-touched.test.js.
11 changes: 11 additions & 0 deletions .ai/contexts/viewer-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,3 +601,14 @@ inline styles permitted, no eval permission.
stubbing only the lazy Mermaid module edge. It covers strict config, laziness,
frontmatter, raw-HTML lookalikes, errors, hostile SVG, ID/reference isolation,
async replacement, cleanup, theme changes, scrolling CSS and build wiring.

## Bounds

read-file-for-panel refuses non-regular files and files above 2 MiB after the
Git metadata and sensitive-path guards, before reading. Binary and invalid
UTF-8 files are refused. Its optional image flag enables signature-based raster
responses after those guards and before the binary check; only local Touched
requests that flag. Responses contain MIME, base64 bytes and byte size.
Without image, the text route is retained. Size and binary errors retain their
messages and add reasons too-large and binary. ViewerPanel does not request
images; its preview route remains owned by #827.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

## Unreleased

### New

- Images opened from a link or from the Touched list now show in the panel. Other files the panel can't show offer **Open with system viewer** for pictures and PDFs, and **Show in folder** for the rest. (#1025)

### Fixed
- A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017)

Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .c
// public/sidebar.js (and equivalents). Kept readonly so reassignment
// from another file gets flagged.
const rendererCrossFileGlobals = {
openTouchedImage: 'readonly',
renderPanelFileRefusal: 'readonly',
validateSessionStopPlan: 'readonly',
executeSessionStopTargets: 'readonly',
confirmAndStopProjectFolder: 'readonly',
Expand Down
27 changes: 24 additions & 3 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const { execFile, spawn: spawnChild } = require('child_process');
const path = require('path');
const fs = require('fs');
const { sniffPanelImage, SYSTEM_VIEWER_EXTENSIONS } = require('./src/main/files/panel-image');
const os = require('os');
const pty = require('node-pty');
const log = require('electron-log');
Expand Down Expand Up @@ -80,7 +81,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./src/main/terminals/shell-profiles');

Check warning on line 84 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleLaunchRefusal, resolveScheduleSandbox, initialScheduleProjects, scanSchedules, setScheduleEnabled } = require('./src/main/schedules/schedule-runner');
const { buildScheduleList } = require('./src/main/schedules/schedule-list');
const { archivePlanForGroups, validArchiveGroups } = require('./archived-projects');
Expand Down Expand Up @@ -590,8 +591,8 @@
getMainWindow: () => mainWindow,
log,
});
const { readSessionFile, readFolderFromFilesystem,

Check warning on line 594 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 594 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
backfillEntrypoints, backfillCustomTitles, revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 595 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
// see .ai/contexts/session-cache.md ("Projects query worker")
const { createProjectsWorkerClient } = require('./projects-worker-client');
Expand Down Expand Up @@ -1184,15 +1185,31 @@
});

// --- IPC: open a local file in the OS default app (terminal right-click menu) ---
ipcMain.handle('open-path', (_event, filePath) => {
ipcMain.handle('open-path', (_event, filePath, opts) => {
if (typeof filePath !== 'string' || !filePath) return;
const resolved = path.resolve(filePath);
if (opts?.viewer && !SYSTEM_VIEWER_EXTENSIONS.includes(path.extname(resolved).toLowerCase())) return;
// Mirror the read/save-file-for-panel guard: never hand a sensitive path
// (~/.ssh, credentials, etc.) to the OS default opener.
if (isSensitivePath(resolved)) return;
return shell.openPath(resolved);
});

ipcMain.handle('show-item-in-folder', (_event, filePath) => {
try {
if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return { ok: false, reason: 'invalid-path' };
const resolved = path.resolve(filePath);
if (gitChangesFile.hasGitSegment(filePath)) return { ok: false, reason: 'git-dir' };
if (gitChangesFile.hasGitSegment(fs.realpathSync.native(resolved))) return { ok: false, reason: 'git-dir' };
if (isSensitivePath(resolved)) return { ok: false, reason: 'sensitive' };
if (!fs.statSync(resolved).isFile()) return { ok: false, reason: 'not-file' };
shell.showItemInFolder(resolved);
return { ok: true };
} catch (error) {
return { ok: false, reason: error.message || String(error) };
}
});

// --- IPC: read clipboard (terminal right-click paste; see clipboard-write-text) ---
ipcMain.handle('read-clipboard', () => clipboard.readText());

Expand Down Expand Up @@ -1249,10 +1266,14 @@
const stat = fs.statSync(resolved);
if (!stat.isFile()) return { ok: false, error: 'not a regular file' };
if (stat.size > PANEL_FILE_MAX_BYTES) {
return { ok: false, error: 'file too large to display' };
return { ok: false, error: 'file too large to display', reason: 'too-large' };
}
const buf = fs.readFileSync(resolved);
if (buf.includes(0)) return { ok: false, error: 'binary file' };
if (opts?.image) {
const mime = sniffPanelImage(buf);
if (mime && (!['image/gif', 'image/webp'].includes(mime) || buf.includes(0))) return { ok: true, image: { mime, base64: buf.toString('base64'), size: buf.length } };
}
if (buf.includes(0)) return { ok: false, error: 'binary file', reason: 'binary' };
const content = gitChangesFile.decodeUtf8(buf);
if (content === null) return { ok: false, error: 'file is not valid UTF-8', reason: 'encoding' };
if (opts?.editor) {
Expand Down Expand Up @@ -2977,7 +2998,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 3001 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
3 changes: 2 additions & 1 deletion preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ contextBridge.exposeInMainWorld('api', {
checkDeleteWorktree: (worktreePath) => ipcRenderer.invoke('check-delete-worktree', worktreePath),
worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
openPath: (filePath) => ipcRenderer.invoke('open-path', filePath),
openPath: (filePath, opts) => ipcRenderer.invoke('open-path', filePath, opts),
showItemInFolder: (filePath) => ipcRenderer.invoke('show-item-in-folder', filePath),
toggleFullScreen: () => ipcRenderer.invoke('toggle-full-screen'),
popupAppMenu: (x, y) => ipcRenderer.invoke('popup-app-menu', { x, y }),
writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text),
Expand Down
62 changes: 59 additions & 3 deletions public/file-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,22 @@ function openTouchedEditor(sessionId, absolutePath, pair, returnList, { line = n
}
}

function openTouchedImage(sessionId, absolutePath, image, returnList) {
const state = getSessionState(sessionId);
if (currentPanelSessionId === sessionId) snapshotPanelList(document.getElementById('touched-list'), returnList);
destroyCurrentTab(state, { stash: false });
const tab = createChangesTab();
Object.assign(tab, { returnList, label: 'Touched files', absolutePath, filePath: absolutePath,
selectedFile: { path: absolutePath }, loading: false, image, editable: false, noDiff: true });
state.currentTab = tab;
state.panelVisible = true;
watchChangesFile(sessionId, tab, absolutePath);
if (currentPanelSessionId === sessionId) {
showPanel(state);
renderPanel(sessionId);
}
}

function applyChangesPair(tab, pair) {
tab.diffLoading = false;
tab.editable = true;
Expand All @@ -1385,7 +1401,7 @@ function applyChangesPair(tab, pair) {
async function readChangesPair(sessionId, tab, file) {
try {
return tab.absolutePath
? await window.api.readFileForPanel(tab.absolutePath, { editor: true, ...(tab.remote ? { sessionId } : {}) })
? await window.api.readFileForPanel(tab.absolutePath, { editor: true, ...(tab.image ? { image: true } : {}), ...(tab.remote ? { sessionId } : {}) })
: await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged });
} catch (err) {
return { ok: false, error: err?.message || 'this file could not be read' };
Expand Down Expand Up @@ -1428,7 +1444,7 @@ async function refreshChanges(sessionId) {

// A refresh may never replace what the user is typing into — see .ai/contexts/changes-view.md
async function syncOpenChangesFile(sessionId, tab) {
if (!tab.selectedFile || !tab.editable) return;
if (!tab.selectedFile || (!tab.editable && !tab.image)) return;

if (!isChangesBufferDirty(tab)) repointSelectedFile(tab);

Expand All @@ -1440,11 +1456,19 @@ async function syncOpenChangesFile(sessionId, tab) {

if (!result || result.ok === false) {
tab.fileError = (result && result.error) || 'this file could not be read';
if (tab.image) tab.imageRefusal = ['binary', 'encoding', 'too-large'].includes(result?.reason);
if (currentPanelSessionId === sessionId) renderPanel(sessionId);
return;
}
tab.fileError = null;

if (tab.image) {
tab.image = result.image || tab.image;
tab.imageRefusal = !result.image;
if (currentPanelSessionId === sessionId) renderPanel(sessionId);
return;
}

if (isChangesBufferDirty(tab)) {
tab.externalChange = tab.absolutePath && !tab.gitFile && !tab.version
? result.current !== tab.savedContent
Expand Down Expand Up @@ -1479,7 +1503,7 @@ function repointSelectedFile(tab) {
function handleChangesFileChanged(sessionId, filePath) {
const state = filePanelState.get(sessionId);
const tab = state && state.currentTab;
if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return;
if (!tab || tab.type !== 'changes' || (!tab.editable && !tab.image) || !tab.selectedFile) return;
if (tab.selectedFile.path !== filePath || tab.saving) return;
return syncOpenChangesFile(sessionId, tab).catch(() => {});
}
Expand Down Expand Up @@ -2017,6 +2041,38 @@ function buildChangesDiffChrome() {
function renderChangesDiff(sessionId, tab) {
window.setViewerPath(changesDiffTitleEl, tab.selectedFile.path);

changesDiffHostEl.classList.toggle('panel-image-host', !!tab.image);
if (tab.image) {
for (const button of [changesDiffFormatBtn, changesDiffModeBtn, changesDiffSaveBtn, changesDiffReloadBtn]) button.style.display = 'none';
changesDiffHostEl.style.display = '';
changesDiffPreviewEl.style.display = 'none';
changesDiffNoticeEl.style.display = 'none';
changesDiffHostEl.replaceChildren();
destroyChangesEditor(tab);
if (tab.imageRefusal) {
renderPanelFileRefusal(changesDiffHostEl, tab.absolutePath);
} else if (tab.fileError) {
changesDiffHostEl.textContent = `This file can no longer be read: ${tab.fileError}`;
} else {
const img = document.createElement('img');
img.className = 'panel-image';
img.alt = tab.absolutePath.split(/[/\\]/).pop();
img.src = `data:${tab.image.mime};base64,${tab.image.base64}`;
img.classList.toggle('panel-image-actual', !!tab.imageActual);
img.addEventListener('click', () => {
tab.imageActual = !tab.imageActual;
img.classList.toggle('panel-image-actual', tab.imageActual);
});
img.onerror = () => {
if (!img.isConnected) return;
tab.imageRefusal = true;
if (filePanelState.get(sessionId)?.currentTab === tab && currentPanelSessionId === sessionId) renderPanel(sessionId);
};
changesDiffHostEl.appendChild(img);
}
return;
}

const markdown = isMarkdownPath(tab.absolutePath);
const formatted = markdown && !!tab.formatted;
changesDiffFormatBtn.style.display = markdown ? '' : 'none';
Expand Down
5 changes: 5 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -5868,3 +5868,8 @@ body.window-frameless :is(.new-session-popover, .terminal-context-menu, .new-ses
.mermaid-diagram > svg { display: block; max-width: none !important; }
.mermaid-error { color: var(--text-muted); font-size: 12px; }
.mermaid-render-scratch { position: fixed; left: -100000px; top: 0; visibility: hidden; pointer-events: none; }

#changes-diff-host.panel-image-host { overflow: auto; }
.panel-image { align-self: flex-start; flex-shrink: 0; max-width: 100%; max-height: 100%; object-fit: contain; cursor: zoom-in; }
.panel-image.panel-image-actual { max-width: none; max-height: none; cursor: zoom-out; }
.panel-file-refusal { display: flex; align-items: center; gap: 8px; padding: 8px; }
Loading
Loading