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
3 changes: 1 addition & 2 deletions docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,7 @@ Header contract:
- **Must keep back/forward/reload enabled.** Agent-browser uses native commands;
iframe uses parent history and re-resolves its proxy.
- **Must show non-default managed `--key` as a badge, never a title prefix.**
- **Must hide split/zoom below `420px` and nav below `360px`;** minimize and kill
remain.
- Width tiers and the narrow-pane popover: `docs/specs/layout.md` → "Pane header responsive sizing".

Source of truth: `lib/src/components/wall/SurfacePaneHeader.tsx`,
`lib/src/components/wall/agent-browser-screen.ts`,
Expand Down
4 changes: 3 additions & 1 deletion docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshot

**Must prefer known extensions over filename-based text fallbacks; source extensions remain escaped previews.**

**Must run the built-in viewer as a Tool-owned `dor` process**, serving HTML, images, PDF/media, and escaped text/source previews. Markdown is source text; custom viewers may render it. Text previews and HTML/CSS dependency inspection are limited to 8 MiB per file. Text/source previews grant only their opened file and skip dependency inspection. (rationale) Oversized HTML and referenced CSS still stream without dependency inspection. The grant contains at most 256 files: the opened document and statically referenced relative HTML/CSS assets within its directory tree; exceeding that bound fails the open without serving a partial grant. Never expand the grant through root-relative, external, or dynamic references; requests can read only granted paths.
**Must run the built-in viewer as a Tool-owned `dor` process**, serving HTML, images, media, and escaped text/source previews. Markdown is source text; custom viewers may render it. Text previews and HTML/CSS dependency inspection are limited to 8 MiB per file. Text/source previews grant only their opened file and skip dependency inspection. (rationale) Oversized HTML and referenced CSS still stream without dependency inspection. The grant contains at most 256 files: the opened document and statically referenced relative HTML/CSS assets within its directory tree; exceeding that bound fails the open without serving a partial grant. Never expand the grant through root-relative, external, or dynamic references; requests can read only granted paths.

**Must require a user Tool for PDFs**, including files named `README.pdf`. (rationale)

**Must retain the viewer's opened file descriptors until the Tool exits.** Refresh reads those files again, but atomic replacements and changes to the dependency graph require restarting the viewer. Cold restore runs the saved file command with a fresh URL capability; Workspace movement keeps the live binding. The listener's authority is `docs/specs/security-local.md` → Local-file viewer.

Expand Down
41 changes: 41 additions & 0 deletions docs/specs/dor-tool.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,51 @@ A hardcoded Storybook port can disagree with the port it obtains under contentio

## Lifecycle

### September 2026 innerdogfood QC record

The `dor-tool-qc` run began at `4c7f9012` and used the real standalone sidecar,
staged CLI, PTYs, and iframe proxy. At that historical baseline the Tools flag
could reject creation and standalone `dor open` split; both behaviors were
subsequently superseded by always-enabled Tools and eligible inline opening.

Observed passes covered project approval (pending dedupe, decline/re-prompt,
folder-only permission, failed spawn without a PTY, repair and Retry); literal
argv and canonical symlink targets; three concurrent keyed invocations sharing
one Tool, fresh instances, idle/fast-command restart with stable refs; automatic
single-port serving, three-port refusal, and announced port/path selection.
User-rule ordering and explicit overrides worked; malformed user configuration
failed, and project associations did not intercept file opens. Text/Markdown
source, HTML/CSS/image, SVG, audio, and awkward filenames rendered; URL,
directory, missing/unsupported-file and oversized-text cases failed usefully.

Approval controls remained usable at 249×203 pixels. Terminal Context,
minimize/reveal, exit/refocus, and iframe/screencast round trips passed. A clean
harness reload preserved every ID, kind, URL, and Workspace; cross-Workspace
identity stayed scoped. The four final viewer processes and earlier fixture
listeners exited when their Tools closed; both owned harnesses stopped and
private credential captures were deleted.

Full `pnpm test` and `pnpm build` passed during the run, plus 139 focused UI
checks. The PDF-policy follow-up passed 168 CLI tests, 20 host dispatch/proxy
tests, and spec/public-doc lints; staged hosts contained no PDF renderer assets.
These counts describe that run, not the current test inventory.

One development-state reset made Tools appear as terminals while root tests
and builds ran beside the harness. Investigation confirmed that
`e2e-lint-selftest` temporarily mutates Vite inputs, including invalid root
package JSON; the exact metadata-loss trigger was not captured. A clean
restart and stable-build reload passed. This run did not exercise Tool transfer,
native-window movement, native Tauri/VS Code rendering, Windows shells, or cold
restore. Screenshots and raw JSON were local ignored artifacts, not portable
verification evidence. The reusable recipe is `docs/testing/dor-tool-qc.md`.


The September 2026 integration reuses Terminal Context for the Tool's primary terminal. The auxiliary helper's automatic refresh, Reset, and Promote semantics do not describe a serving command, whose Session also owns the browser and remote terminal identity. Sharing the presentation avoids introducing a second navigation mechanism or a second shell.

## Opening local files

Innerdogfood QC in Chromium (2026-09) showed the native PDF plugin failing inside the normal iframe sandbox. PDFs use configured user Tools; the built-in viewer carries no PDF renderer dependency.

A CSS source preview escapes its contents, so its URLs cannot load assets. Scanning those references adds unused authority and can reject a small source file at the asset limit. CSS loaded by HTML is active, so its dependencies still enter the bounded grant.

Keeping the built-in viewer in the Tool's process tree reuses port discovery, kill, restart, and Workspace transfer. An OSC path carries the per-run URL capability without saving that secret in the restart command. Holding the selected file descriptors bounds what the server can read after launch; it trades automatic replacement-file refresh for a grant whose contents cannot widen through path replacement.
Expand Down
21 changes: 17 additions & 4 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,24 @@ Both layers wear the leaf's own rounding (header radius on top, terminal radius

### Pane header responsive sizing

A ResizeObserver picks one of three tiers by header width:
**Must measure each header's own border-box width, never the viewport, retaining its tier at zero width** (rationale). Terminal tiers:

- **Full** (>280px): everything.
- **Compact** (>160px): split, zoom, and unzoom hidden.
- **Minimal** (≤160px): also hides the TODO pill and the mouse-override icon, leaving alert, minimize, and kill. **The notepad icon survives this tier only while the Surface has notes** (`docs/specs/notepad.md` → "Notepad UI"). The label truncates with ellipsis.
- **Full** (>293px): everything.
- **Compact** (>173px): split, zoom, and unzoom hidden.
- **Minimal** (≤173px): also hides the TODO pill and the mouse-override icon, leaving alert, minimize, and kill. **The notepad icon survives this tier only while the Surface has notes** (`docs/specs/notepad.md` → "Notepad UI"). The label truncates with ellipsis.

A browser header, including a Tool's (Terminal Context sits outside the measured width), collapses by border-box width:

| Below | Change |
|---|---|
| 420px | Split and zoom hidden. |
| 360px | Navigation hidden. |
| 180px | Chrome moves into a viewport-clamped popover behind one trigger; minimize and kill stay inline. |
| 72px | Minimize and kill join the popover. |

**Must reclamp the popover when its content resizes and keep it keyboard reachable** (focus enters on open, Tab stays inside, Escape returns it to the trigger) **and dismiss it on a pane resize or, without restoring focus, when its Surface is hidden**; otherwise `lib/src/components/wall/use-dismiss-overlay.ts` applies, and a control inside dismisses only after its action ran. The trigger shows a filled notepad glyph and the note count while the Surface has notes; long keys and connection labels truncate before controls.

Source of truth: `SurfacePaneHeader` in `lib/src/components/wall/SurfacePaneHeader.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useHeaderTier` in `lib/src/components/wall/use-header-tier.ts`; `lib/src/components/wall/SurfacePaneHeader.test.tsx`; `lib/src/components/wall/TerminalPaneHeader.test.tsx`; `lib/src/stories/BrowserChromeHeader.stories.tsx`.

## Baseboard

Expand Down
8 changes: 8 additions & 0 deletions docs/specs/layout.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

> Informative companion to [layout.md](layout.md): the evidence, measurements, and dead-approach history behind its rules, keyed by that spec's headings (AGENTS.md → "What, not why"). Nothing here is normative.

## Pane header responsive sizing

A viewport breakpoint says nothing about a narrow split inside a wide window: at a 1200px viewport every control stayed rendered in a 103px pane and overflowed into its neighbor (innerdogfood QC, 2026-09). Tool headers have even less browser width because Terminal Context occupies its own button. Measuring the header and moving fixed controls together keeps long keys, note buttons, and renderer chips from pushing minimize/kill into a neighboring pane; quantizing the measurement to a tier keeps the header from re-rendering on every frame of a sash drag or tween.

In the same run, real clicks exposed premature popup dismissal before the action ran. After repair, Zoom reached 716×403 pixels, Unzoom returned to the compact header, Reload worked, and Display retained modal focus. Header buttons stayed within their panes at the final 1200×800 viewport.

Terminal border-box thresholds of 293/173 pixels preserve the former 280/160 content-box thresholds plus 13 pixels of horizontal padding. A content box can clamp to zero in a visible tiny leaf; treating that as hidden retained the full tier. Positive border-box width distinguishes that case from a hidden leaf.

## Pane body

xterm.js paints only its own rendered surface, and integer row fitting leaves a sub-row remainder at the bottom of the pane: a host background differing from the terminal screen shows as a stripe under the last row, and an unclipped host squares off the rounded bottom corners.
Expand Down
44 changes: 44 additions & 0 deletions docs/testing/dor-tool-qc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Dor Tool innerdogfood QC recipe

Use this fixture with the current contracts in `docs/specs/dor-tool.md` and
`docs/specs/layout.md`. Historical September 2026 results and coverage limits
live in `docs/specs/dor-tool.rationale.md` → Lifecycle; header findings live in
`docs/specs/layout.rationale.md` → Pane header responsive sizing.

## Harness and isolation

1. Run source-mutating root self-tests before starting the live harness. Start
`dor ensure -- pnpm innerdogfood` from the checkout under test. Use the
printed browser command through `dor ab`; the harness provides real sidecar
PTYs, a staged CLI, and the iframe proxy.
2. Keep generated files and a separate XDG user configuration under the ignored
`standalone/src-tauri/target/dor-tool-qc/` directory. Use that configuration
for inner CLI invocations; leave the installed application's configuration
and trust records untouched. Capture inner CLI credentials only to a
mode-0600 local file, never to a report.
3. From an inner terminal, start the fixture as a Tool:
`dor tool -- node scripts/dor-tool-qc/server.mjs --label QC --ports 1`.
Adjust the fixture path if that terminal starts outside the checkout.

## Serving and interaction checks

The fixture accepts `--ports N`, `--label TEXT`, `--path PATH`, and `--announce`.
It prints its PID, listening ports, and argv. `--ports 3` creates a port conflict;
`--announce` selects the first port through OSC 367. On POSIX, send `SIGUSR1` to
the printed PID to announce after startup. The page echoes the requested path
and argv, and its text input makes document-state retention visible.

Use the fixture to exercise narrow header controls, popup actions and focus,
Terminal Context, minimize/reveal, renderer changes, and live reload. Add local
files and temporary user/project `dormouse.yml` declarations for approval,
argument quoting, keyed reuse/restart, and file dispatch. Record the tested
commit, observed outcomes, and coverage limits when collecting new evidence.
The browser harness does not establish native Tauri/VS Code rendering,
native-window transfer, or Windows shell behavior.

## Cleanup

Close every Tool created by the run, verify its printed PID and listeners have
exited, then stop the owned harness. Remove private credential captures. Keep
any screenshots and raw observations ignored locally; summarize durable
findings in the owning spec's rationale rather than linking private artifacts.
2 changes: 1 addition & 1 deletion dor/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const openCommand: Command = {
brief: 'Open a local file with a Dor Tool.',
fullDescription: `Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted.

The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, PDF, and media files. Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml.
The first matching rule in the user dormouse.yml selects a user Tool or builtin:file. --tool chooses a handler explicitly. Without a matching rule, the built-in viewer opens supported HTML, text/source, image, and media files. PDFs require a user Tool association or --tool <name>. Use --tool builtin:file to select it explicitly. Markdown is shown as source text; a user Tool can provide rendered Markdown. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml.

The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The built-in HTML viewer serves statically referenced relative assets within the document directory tree; root-relative and external resources are unavailable. Text previews are capped at 8 MiB.

Expand Down
5 changes: 4 additions & 1 deletion dor/src/file-viewer-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tool association rather than being guessed to be text. */
const MIME: Record<string, string> = {
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon',
css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
json: 'application/json', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
Expand All @@ -20,6 +20,9 @@ export const VIEW_FILE_ARGV = '__view-file';
export function fileViewerFormat(path: string): { mime: string; text: boolean } | null {
const name = path.replace(/\\/g, '/').split('/').pop()!.toLowerCase();
const ext = name.includes('.') ? name.split('.').pop()! : '';
// PDF plugins cannot run inside the viewer's iframe sandbox. Exclude PDFs
// before source-name heuristics so README.pdf never becomes a text preview.
if (ext === 'pdf') return null;
const knownMime = Object.prototype.hasOwnProperty.call(MIME, ext) ? MIME[ext] : undefined;
const text = TEXT.has(ext) || (!knownMime && /^(readme|license|licence|makefile|dockerfile|\.gitignore|\.env)(\..*)?$/.test(name));
const mime = knownMime ?? (text ? 'text/plain; charset=utf-8' : null);
Expand Down
13 changes: 7 additions & 6 deletions dor/test/file-viewer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ async function get(viewer, path = viewer.path, headers = {}, method = 'GET') {
}
const asset = (viewer, path) => viewer.path.replace(/\/file\/.*$/, `/file/${path}`);

test('known formats override source-name heuristics without treating prototype keys as formats', () => {
for (const [name, mime] of [['README.pdf', 'application/pdf'], ['readme.png', 'image/png'], ['LICENSE.html', 'text/html; charset=utf-8']]) {
test('known formats override source-name heuristics, PDFs never preview, and prototype keys are not formats', () => {
for (const [name, mime] of [['readme.png', 'image/png'], ['LICENSE.html', 'text/html; charset=utf-8']]) {
assert.deepEqual(fileViewerFormat(name), { mime, text: false });
}
for (const name of ['report.pdf', 'README.pdf', 'LICENSE.PDF']) assert.equal(fileViewerFormat(name), null, name);
for (const name of ['README', 'Dockerfile.dev', 'README.md', '.gitignore']) {
assert.deepEqual(fileViewerFormat(name), { mime: 'text/plain; charset=utf-8', text: true });
}
Expand Down Expand Up @@ -100,12 +101,12 @@ test('rejects parent-directory references and symlinks escaping the document dir
assert.notEqual((await get(viewer, asset(viewer, '../secret.txt'))).status, 200);
});

test('supports byte ranges and HEAD for native PDF/image presentation', async () => {
const viewer = await start('README.pdf', '%PDF-1.7 example bytes');
assert.equal((await get(viewer)).headers['content-type'], 'application/pdf');
test('supports byte ranges and HEAD for media presentation', async () => {
const viewer = await start('sample.wav', 'RIFF example bytes');
assert.equal((await get(viewer)).headers['content-type'], 'audio/wav');
const range = await get(viewer, viewer.path, { Range: 'bytes=0-3' });
assert.equal(range.status, 206);
assert.equal(range.body, '%PDF');
assert.equal(range.body, 'RIFF');
assert.equal((await get(viewer, viewer.path, { Range: 'bytes=-5' })).body, 'bytes');
assert.equal((await get(viewer, viewer.path, { Range: 'bytes=999-1000' })).status, 416);
assert.equal((await get(viewer, viewer.path, { Range: 'bytes=0-1,4-6' })).status, 416);
Expand Down
Loading