Terminal file viewer/editor + per-window app-command lockdown (#60)
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s

Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label.

Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #60.
This commit is contained in:
2026-09-23 17:05:50 +00:00
co-authored by Claude Opus 5.5
parent 3537b234d8
commit 8305c96e20
65 changed files with 15043 additions and 108 deletions
@@ -0,0 +1,536 @@
# App-command lockdown via Tauri AppManifest — design
Date: 2026-09-22
Status: approved in conversation (user); revised against the implemented viewer (`bf22910`) and
the review rulings in "Decisions made during review"; plan at
`docs/superpowers/plans/2026-09-22-app-manifest-lockdown.md`
Follows: `2026-09-22-terminal-file-viewer-design.md` (the viewer windows this exists to confine)
## Goal
Make every application command (the `generate_handler!` list in `lib.rs`) permission-gated per
window, so a `file-viewer-*` window can invoke exactly its five `viewer_*` commands and nothing
else, and the `main` window keeps exactly what it invokes today. Close the residual risk the
viewer spec records in its §6: "a compromised viewer window can still invoke other app commands,
because `build.rs` does not restrict app commands per window."
Non-goals: scoping arguments of app commands (the ACL only allows/denies command names; argument
validation stays in Rust as today), changing any plugin grant, `removeUnusedCommands` (see §3.6).
## 1. Current state (verified)
- `app/src-tauri/build.rs` is `tauri_build::build()`, i.e. `try_build(Attributes::default())`
with an empty `AppManifest` (tauri-build 2.6.0 `src/lib.rs:454`).
- Versions from `Cargo.lock`: tauri 2.11.0, tauri-build 2.6.0, tauri-utils 2.9.0,
tauri-codegen 2.6.0, tauri-macros 2.6.0. Everything below is read from those sources under
`~/.cargo/registry/src/index.crates.io-*/`.
- 115 commands are registered in `generate_handler!` (`lib.rs:469-607`): the 109 that predate
the viewer plus its six (`open_file_viewer`, `viewer_get_state`, `viewer_choose_file`,
`viewer_read_file`, `viewer_poll_file`, `viewer_write_file`). All 115 are invoked from
`app/src`, every one through an `export const` wrapper in `lib/tauri-commands.ts`, which is
the only non-test file that imports `@tauri-apps/api/core` (the earlier direct
`invoke("terminal_input")` in `hooks/useTerminal.ts` is gone; only a comment mentions it).
No invoke name exists without a registration. None uses `#[command(rename = …)]`, so the
invoke name is always the function identifier.
- `capabilities/default.json` is `windows: ["main"]` and grants five plugin/core permissions.
`capabilities/file-viewer.json` is `windows: ["file-viewer-*"]` and grants four core
permissions. `tauri.conf.json` has no `app.security.capabilities` key, so every file under
`capabilities/` is active (tauri-build `acl.rs:424-429`).
- Both files already have an exact-set census test: `the_capability_grants_are_the_ones_that_were_reviewed`
(`lib.rs:924`) for `default.json`, and `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`
plus `the_default_capability_is_scoped_to_the_main_window_only` (`file_viewer/mod.rs:63,89`)
for `file-viewer.json` and the `windows` lists. All three will fail as written the moment
bare `allow-*` grants appear and are updated in the same change (§3.4).
- The viewer's five `viewer_*` commands take `window: tauri::Window` and gate on the caller's
label inside `commands/file_viewer_commands.rs`; `open_file_viewer` is main-only by the same
mechanism. The `default.json` description, `file-viewer.json` description and `CLAUDE.md`
(Backend Structure and Key Conventions) all currently describe app commands as ungated by
capability and point at this spec as the pending fix; all three are rewritten here (§3.5).
- The only other non-main window is the browser-view pop-out, label `browser-view-<id>`
(`browser_view/popout.rs:71,104`), `WebviewUrl::External`. No capability names it and no
capability has `remote`, so it has no IPC at all — before and after this change.
- `gen/schemas/acl-manifests.json` and `gen/schemas/capabilities.json` are **tracked in git**
and rewritten by tauri-build on every build; the app manifest will show up there as a
reviewable diff.
- CI (`.gitea/workflows/build-app-preview.yml`, `build-app.yml`) runs `npm run build`
(`tsc && vite build`) and `tauri build`. It runs **neither `cargo test` nor `vitest`**. Any
check that must hold on every merge therefore has to fail the *build*, not a test.
## 2. Mechanism (tauri-build 2.6.0 / tauri-utils 2.9.0 / tauri 2.11.0)
### 2.1 What `AppManifest::commands` generates
```rust
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["check_docker", /**/])),
)
```
- `commands` takes `&'static [&'static str]` (`tauri-build/src/acl.rs:87-114`).
- `app_manifest_permissions` (`acl.rs:265-335`) calls
`tauri_utils::acl::build::autogenerate_command_permissions`, which for each command writes
**into the source tree** at `src-tauri/permissions/autogenerated/<command>.toml`
(`tauri-utils/src/acl/build.rs:289-317`):
```toml
[[permission]]
identifier = "allow-<command with _ → ->"
commands.allow = ["<command verbatim>"]
[[permission]]
identifier = "deny-<command with _ → ->"
commands.deny = ["<command verbatim>"]
```
Naming: only `_` is replaced by `-` (`build.rs:290`). `viewer_read_file` →
`allow-viewer-read-file`. Identifiers may not contain `_` (`identifier.rs:96-109`), so the
kebab form is mandatory in capability files; the command name inside stays snake_case.
- Then `permissions/**/*` is globbed too (default `permissions_path_pattern`), so any hand-written
`.toml`/`.json` under `permissions/` is also part of the app manifest. **Stale files are never
deleted**: a command dropped from `.commands()` leaves its `.toml` behind, still defining a
permission a capability can reference. §3.2 handles this.
- No app `default` permission set is generated (`acl.rs` has no such logic; only
`InlinedPlugin` has `DefaultPermissionRule`).
- The manifest is stored under key `__app-acl__` (`APP_ACL_KEY`, `tauri-utils/src/acl/mod.rs:50`)
in `gen/schemas/acl-manifests.json`, copied to `OUT_DIR/acl-manifests.json`, and embedded by
`generate_context!` (`tauri-codegen/src/context.rs:393-417`, `Resolved::resolve`).
- The app manifest exists iff it has at least one permission, permission set or default
(`acl.rs:400-443`, `has_app_manifest`). `.commands(&[])` is the same as no manifest.
### 2.2 How capabilities reference app permissions
- Bare identifier, no prefix: `"allow-viewer-read-file"`. Resolution takes
`permission_id.get_prefix().unwrap_or(APP_ACL_KEY)` (`resolved.rs:340-372`); `core:event:…`
and `dialog:…` keep working unchanged.
- `windows` / `webviews` are glob patterns via `glob::Pattern` (`resolved.rs:199-208`,
matched in `authority.rs:460-461`), so `"file-viewer-*"` is valid. Labels are **not**
validated against configured windows at build time (no such check in tauri-build or
tauri-codegen).
- `local` defaults to `true`; both the bundled `tauri://localhost` document and the Vite
`devUrl` count as local (`webview/mod.rs:1698-1740`, `is_local_url`: tauri protocol, or
relative to `devUrl`/`frontendDist`). The viewer's `viewer.html` is served the same way as
`index.html`, so it is local in both dev and release.
### 2.3 Runtime enforcement
`Webview::on_message` (`tauri-2.11.0/src/webview/mod.rs:1794-1849`):
```rust
// we only check ACL on plugin commands or if the app defined its ACL manifest
if (plugin_command.is_some() || has_app_acl_manifest)
&& request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND
&& invoke.acl.is_none()
{ /* reject */ }
```
- **Today** (`has_app_acl_manifest == false`): app commands skip the ACL entirely. That is the
hole.
- **With a manifest**: `resolve_access` (`ipc/authority.rs:439-471`) must return an allow whose
capability `windows`/`webviews` glob matches the caller and whose context matches the origin.
Otherwise the invoke is rejected **before** the `generate_handler!` closure runs; the
`#[command]` wrappers do no allow/deny logic of their own.
- **Unlisted command = denied.** A registered command with no `allow-*` permission anywhere never
enters `allowed_commands`, so `resolve_access` returns `None`. Debug message:
`"<cmd> not allowed. Command not found"`. There is no build-time warning for this case.
- **Listed but not registered** = a permission nobody can use; no build error. If granted and
invoked, the handler's fallthrough returns "unhandled" — harmless, but §3.2 rejects it anyway.
- **`deny-*` is global.** `resolve_access` tests `denied_commands.get(cmd).map(..).is_some()`,
which is true for any deny entry regardless of window or origin. Never grant a `deny-*` in the
viewer capability expecting it to stay confined; the design uses allow-lists only, and the
build check bans `deny-*` outright.
- Denial text: debug builds get `resolve_access_message` (`authority.rs:229-437`), e.g.
`"<cmd> not allowed. Permissions associated with this command: allow-<slug>"` or
`"<cmd> not allowed on window \"file-viewer-3\", … allowed on: [windows: \"main\", …]"`.
Release builds get `"Command <cmd> not allowed by ACL"`. The frontend's `refusalText.ts`
wrapper-stripping already tolerates the `invoke failed:`/`Error:` prefixes.
### 2.4 Build-time failure modes
- Unknown bare permission in a capability file (typo, or a command missing from the manifest):
`validate_capabilities` (`tauri-build/src/acl.rs:353-392`) bails with
`Permission <id> not found, expected one of …`; `build()` exits 1. This also fires for any
bare identifier when there is no app manifest at all.
- Capabilities inlined in `tauri.conf.json` are *not* validated there and fail later in
`generate_context!` as a panic (`failed to resolve ACL`). Keep capabilities as files.
- Duplicate capability identifiers across files are an error (`build.rs:204-244`).
- A bad glob in `windows` is a resolve-time error.
- The build script's CWD is `src-tauri`; the `./permissions/autogenerated` path is relative to it.
### 2.5 Dev vs release
Enforcement is identical. `cfg(debug_assertions)` changes only the error text and whether the
raw ACL map is retained for messages (`authority.rs:29-30, 77-101`). `tauri dev` versus `tauri
build` differs only in `custom-protocol`, which does not touch the ACL. A command missing from a
capability is therefore caught the first time anyone opens the affected screen in `tauri dev`,
with a message naming the permission.
## 3. Design
### 3.1 Capability files
`capabilities/default.json` (unchanged `windows: ["main"]`) gains one bare `allow-<slug>` entry
per main-window command — 110: the 109 that predate the viewer plus `open_file_viewer`. The
existing five plugin/core grants stay exactly as they are. The `description` is amended (§3.5).
`capabilities/file-viewer.json` (exists, `windows: ["file-viewer-*"]`, four core grants)
gains exactly `allow-viewer-get-state`, `allow-viewer-choose-file`, `allow-viewer-read-file`,
`allow-viewer-poll-file`, `allow-viewer-write-file`, beside its four core grants.
No permission sets, no `default`, no hand-written files under `permissions/`. The project's
existing test already refuses `*:default` aliases because they expand to sets the file does
not spell out; the same rule applies to app permissions. Enumeration is the point.
Assignment of every command (110 go to **main**; nothing but the viewer's five goes to
**file-viewer**). The rule is a function, not a table: a command whose name starts with
`viewer_` belongs to `file-viewer-*`, every other command belongs to `main`
(`command_census::expected_windows`, §3.2). The table is the rule applied to today's list:
| Group (`lib.rs` comment) | Commands | Window |
|---|---|---|
| Docker | `check_docker`, `check_image_exists`, `build_image`, `get_container_info` | main |
| Projects | `list_projects`, `add_project`, `remove_project`, `update_project`, `start_project_container`, `stop_project_container`, `rebuild_project_container`, `reconcile_project_statuses` | main |
| Notes | `list_notes`, `save_note`, `delete_note` | main |
| Migration | `get_container_staleness`, `migrate_project_to_base`, `confirm_migration`, `rollback_migration`, `get_migration_state` | main |
| Auth bridge | `set_auth_bridge_enabled`, `get_auth_bridge_status` | main |
| Browser view | `set_browser_view_enabled`, `get_browser_view_status`, `check_browser_view_support`, `install_browser_view_support`, `install_browser_view_browser`, `open_browser_view_popout`, `close_browser_view_popout`, `get_browser_view_popout_state`, `set_browser_view_popout_always_on_top`, `open_page_in_container_browser`, `set_container_page_viewport`, `get_container_page_state`, `close_container_page`, `set_browser_view_match_window`, `get_browser_view_match_window` | main |
| Claude token | `acquire_claude_token`, `submit_claude_token_code`, `cancel_claude_token`, `has_claude_token`, `clear_claude_token`, `sweep_claude_token_snapshots` | main |
| Settings | `get_settings`, `update_settings`, `pull_image`, `detect_aws_config`, `inspect_ca_cert_path`, `list_aws_profiles`, `detect_host_timezone` | main |
| Settings export/import | `export_settings`, `preview_settings_import`, `apply_settings_import` | main |
| Terminal | `open_terminal_session`, `terminal_input`, `terminal_resize`, `close_terminal_session`, `paste_image_to_terminal`, `upload_host_file_to_terminal`, `start_audio_bridge`, `send_audio_data`, `stop_audio_bridge` | main |
| Files | `list_container_files`, `download_container_backup`, `download_container_file`, `upload_files_to_container`, `read_container_file`, `rename_container_path`, `create_container_directory` | main |
| AWS | `aws_sso_refresh` | main |
| Updates | `get_app_version`, `check_for_updates`, `check_image_update` | main |
| Help | `get_help_content` | main |
| URL open | `open_url_external` | main |
| Install helper | `detect_install_options`, `run_docker_install` | main |
| Web terminal | `start_web_terminal`, `stop_web_terminal`, `get_web_terminal_status`, `regenerate_web_terminal_token` | main |
| STT | `get_stt_status`, `start_stt`, `stop_stt`, `build_stt_image`, `pull_stt_image`, `transcribe_audio` | main |
| Gateway | `get_gateway_status`, `start_gateway`, `stop_gateway`, `check_gateway_health`, `build_gateway_image`, `pull_gateway_image`, `set_gateway_api_key`, `clear_gateway_api_key`, `get_gateway_auth_token`, `regenerate_gateway_auth_token` | main |
| Inspect | `list_claude_sessions`, `resume_session_command`, `list_container_capabilities`, `list_scheduled_tasks`, `add_scheduled_task`, `update_scheduled_task`, `get_scheduled_task_log`, `set_scheduled_task_enabled`, `run_scheduled_task_now`, `remove_scheduled_task`, `get_scheduler_notifications`, `clear_scheduler_notifications` | main |
| Terminal file viewer | `open_file_viewer` | main |
| Terminal file viewer | `viewer_get_state`, `viewer_choose_file`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file` | file-viewer |
Windows other than main: `browser-view-*` (pop-out) invokes nothing and must stay listed in no
capability — the pane's iframe inside main is a remote origin and has no IPC either. The web
terminal is a WebSocket server, not IPC, and is unaffected.
The Rust label checks in the viewer commands (`open_file_viewer` main-only; `viewer_*`
caller's-own-label-only) stay. The ACL says *which* window may call; the label check says *whose*
registry entry it operates on. They are not redundant.
### 3.2 `build.rs`: manifest derived from `generate_handler!`, checked against capabilities
The manifest must not be a second hand-maintained list. The logic lives in a new
`src/command_census.rs` — plain functions over strings and `serde_json::Value`, no reference to
the crate — that is compiled into **both** the build script (`#[path = "src/command_census.rs"]
mod command_census;` in `build.rs`) and the test build (`#[cfg(test)] mod command_census;` in
`lib.rs`), so the parser the build uses is the parser the tests test and the two cannot drift.
Its surface:
```rust
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>>; // handler list, in order, duplicates kept
pub fn allow_permission(command: &str) -> String; // "viewer_read_file" → "allow-viewer-read-file"
pub fn expected_windows(command: &str) -> &'static [&'static str]; // "viewer_*" → ["file-viewer-*"], else ["main"]
pub struct CapabilityFile { pub name: String, pub windows: Vec<String>, pub bare: Vec<String> }
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String>;
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String>; // every violation, or empty
```
`build.rs` becomes:
1. `registered_commands` is the line-based extraction the existing
`every_command_is_registered_exactly_once` test uses today (`lib.rs:849-870`), moved
verbatim; that test calls it too. `println!("cargo:rerun-if-changed=…")` for `src/lib.rs`,
`src/command_census.rs` and the `capabilities` directory.
2. **Fail closed on an empty or malformed parse.** An empty list makes `.commands(&[])` a no-op
and silently restores today's unguarded state, so `check` reports (and `build.rs` exits 1 on)
an empty list, a name outside `[a-z0-9_]+`, or a duplicate.
3. Delete every `permissions/autogenerated/<name>.toml` whose `<name>` is not a registered
command before calling tauri-build, so a command removed from `lib.rs` cannot leave a stale
permission behind. **Not** `remove_dir_all`: tauri-build emits
`cargo:rerun-if-changed=permissions` (spike §7.I), so regenerating every file on every build
would give them fresh mtimes and re-run the build script — and recompile the crate — on
every `cargo` invocation forever. Live files are left alone; tauri-utils only rewrites them
when their content changes (`write_if_changed`, `acl/build.rs:308`), so the build settles.
Also refuse anything under `permissions/` other than `autogenerated/`: tauri-build globs
hand-written permission files into the manifest, which would be a second source of grants
that the census does not see. Add `app/src-tauri/permissions/autogenerated/` to
`.gitignore`; the reviewable artifact is `gen/schemas/acl-manifests.json`, already tracked.
4. Read every `capabilities/*.json` with `capability_file` (bare = identifiers without `:`;
scoped-object grants are read by their `identifier`), and `check` asserts:
- every registered command's `allow-<slug>` appears in **exactly one** capability file
(missing → names the command and the `windows` of the file it belongs in; more than one →
names the files);
- every bare identifier is `allow-<slug>` of a registered command (a typo is named; tauri's
own `validate_capabilities` would also fail, but its message dumps every generated
`allow-*`/`deny-*` identifier);
- no bare `deny-*` (global, §2.3) and nothing else bare that is not `allow-*` (so no bare
`default`);
- the file that grants a command has `windows` equal to `expected_windows(command)` — the
"which side does this belong to" rule decided in one place, and the reason the `viewer_*`
naming prefix is now load-bearing.
All violations are reported together, so ten forgotten grants are one build failure.
5. `tauri_build::try_build(Attributes::new().app_manifest(AppManifest::new().commands(leaked)))`
where `leaked: &'static [&'static str]` comes from `Box::leak` (ruling: acceptable; the
process is short-lived and it is two lines), propagating the error the way
`tauri_build::build()` does (print and exit 1).
Because this runs inside the build script, it fails `cargo check`, `cargo test`, `tauri dev`
and CI's `tauri build` alike — the one place a check is guaranteed to run on every merge (§1).
`serde_json` is already a dependency and is added to `[build-dependencies]` (already in
`Cargo.lock`, so `--offline` still resolves).
Why "exactly one" rather than "at least one": today no command is shared between windows, and
a command that genuinely needs both is a design change worth a visible edit to
`expected_windows`, not a silent widening.
### 3.3 Frontend check (vitest)
`app/src/test/capabilities.test.ts`, following `icon-config.test.ts`'s pattern of reading
`src-tauri/` files with `readFileSync`. The earlier draft split invoke literals by directory;
that is wrong for the tree as built, because the viewer does not call `invoke` itself — it
imports wrappers from the shared `lib/tauri-commands.ts` (`viewerGetState`, `viewerChooseFile`
in `ViewerApp.tsx`; `viewerReadFile`, `viewerPollFile`, `viewerWriteFile` in `EditorPane.tsx`).
So the test follows imports instead, and does so by parsing every file with the TypeScript
compiler (`ts.createSourceFile`), not a regex scan, so comments, strings, template substitutions
and regex literals are the parser's problem rather than ours:
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`**, checked against *every*
code file under `src/` — tests included, not just the files the two closures below cover,
because the viewer's closure can reach anything a relative import can. A specifier this scan
cannot read as a literal (a computed `import()`/`require()` argument) is treated as an offender
too, so an un-auditable dynamic import fails the same way a literal one to
`@tauri-apps/api/core` would. That is what makes the rest of the test complete: no other file
can invoke.
- The wrapper map is read from `tauri-commands.ts`'s AST: each exported `const NAME = …` must
call `invoke` exactly once, inside its own function body (not at module load, not inside an
IIFE, not as a default argument), with a string-literal command name matching Rust's
`[a-z0-9_]+` (115 wrappers, 115 commands today). Zero or more than one call, a non-literal
argument, `invoke` referenced other than as a direct call, or any dynamic `import()`/`require()`
in the file at all, fails the test.
- Every other module edge is resolved the way Vite 6 actually resolves it, in Vite's own order:
the exact relative path if it names a file; else, for a `.js`/`.mjs`/`.cjs`/`.jsx` path, its
TypeScript twin (`.js`→`.ts`, then `.tsx`); else `path + ext` over `resolve.extensions`
(`.mjs .js .mts .ts .jsx .tsx .json`) in that order; else, for a directory, `index + ext` in the
same order. A path alias, a query/fragment suffix (`?worker`, `?raw`), a directory import that
would switch Vite to package-entry resolution, or a relative import that resolves outside
`src/`, all fail the test rather than being silently skipped or misresolved — resolution is
fail-closed, not best-effort.
- A wrapper counts as used when a file names it directly (a named import, a named re-export by
name) or reaches it through a namespace import (`import * as X from ".../tauri-commands"`)
**used only as member access**, `X.name` (including `typeof X.name` in a type position). Any
other use of that namespace binding — passed to a function, spread, indexed, aliased again —
fails closed rather than being read as "no wrapper used"; `export *`/`export * as` of the
wrappers file is refused outright, since it cannot be resolved to specific names.
- The **viewer closure** is the transitive set of module targets reachable from
`src/viewer/main.tsx` under that same fail-closed resolution (static imports, `export … from`,
literal `import()`; tests excluded, non-code assets ignored). Today it reaches
`lib/tauri-commands`, `lib/types`, `components/ui/{Button,StatusIndicator,unavailable}` and
`components/projects/home/filePreview`, none of which import a wrapper except the two viewer
files. `V` = commands of the wrappers those files reach; `M` = commands of the wrappers reached
by every other non-test file under `src/`.
- Assert `M ∩ V = ∅`; `allow-<slug>(V)` **equals** the bare set of `file-viewer.json` (no
over-grant to the untrusted window); `allow-<slug>(M)` is a **subset** of the bare set of
`default.json`; and every wrapper's command is in exactly one of the two files (a cheap mirror
of the Rust rule, so a developer running only vitest sees the same failure).
- `default.json`'s five plugin/core grants and `file-viewer.json`'s four core grants are
asserted as exact lists, the same census style as the Rust tests.
This is the test that turns "a forgotten command silently breaks a feature at runtime" into a
red test on the developer's machine for the *window* dimension: the Rust build check (§3.2)
proves every command is granted in the file its name says it belongs in; this one proves the
code that runs in each window only reaches wrappers that window is granted.
### 3.4 Rust test updates
- `lib.rs` `the_capability_grants_are_the_ones_that_were_reviewed`: split `listed` into prefixed
and bare. The prefixed census stays the exact five-element list. The bare set must equal
`allow_permission` over the registered commands whose `expected_windows` is `["main"]`
(110 today). Keep the `:default` and `store:` refusals.
- `file_viewer/mod.rs` `the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions`:
the exact list becomes the four core grants plus `allow-viewer-get-state`,
`allow-viewer-choose-file`, `allow-viewer-read-file`, `allow-viewer-poll-file`,
`allow-viewer-write-file`. `the_default_capability_is_scoped_to_the_main_window_only` is
unchanged.
- New `lib.rs` test `the_generated_app_manifest_matches_the_handler_list`: `cargo test` runs
`build.rs`, so `gen/schemas/acl-manifests.json` is fresh; assert its `__app-acl__` entry's
permission identifiers equal `allow-<slug>` `deny-<slug>` over the registered set exactly.
This is the end-to-end proof that the parse in `build.rs` produced what tauri embedded,
independent of the parser it shares with §3.2.
- `every_command_is_registered_exactly_once` is unchanged apart from calling
`command_census::registered_commands` instead of its inline copy.
- `command_census.rs` carries its own unit tests for `check` (missing grant, duplicate grant,
typo, `deny-*`, wrong file, empty list, bad name) — these are the tests that make the build
check trustworthy, and they run under `cargo test` without needing a failing build.
### 3.5 Documentation updates (same change)
- `capabilities/default.json` `description` (the threat model of record): replace the sentence
run that begins "App commands stay ungated by capability in both files" and ends "not yet
built" with the closed state: `build.rs` declares an `AppManifest`, so every app command is
ACL-gated per window; the bare `allow-*` entries are the complete list of app commands the
main window may call and are cross-checked by `build.rs` against `generate_handler!` (the
build fails on a missing, misspelled, duplicated or misfiled grant); the `file-viewer-*`
windows are confined to `capabilities/file-viewer.json`; `deny-*` is global in tauri 2.11.0
and is therefore banned by the same check; the pop-out remains capability-less; and the
Rust label gates in `file_viewer_commands.rs` stay because the ACL says *which* window and
the label says *whose* entry. Amend the opening "verified against tauri 2.11.0's `PLUGINS`
table" framing so it no longer implies app commands are outside this file.
- `capabilities/file-viewer.json` `description`: replace "app commands need no entry here and
are gated by label inside `commands/file_viewer_commands.rs`" with: the five `allow-viewer-*`
grants are the only app commands this window can reach, the label gate is still what stops
window A acting on window B's entry, and `build.rs` refuses any other bare grant here.
- `CLAUDE.md` "Key Conventions" (`CLAUDE.md:598-600`): the bullet saying
`capabilities/default.json` grants plugin commands only and app commands "do not need an
entry there" becomes false. Replace with: a new command needs (1) `#[tauri::command]`, (2) a
`generate_handler!` entry, (3) an `allow-<kebab-name>` entry in the one capability file for
the window that calls it (`viewer_*` → `file-viewer.json`, everything else →
`default.json`); `cargo check`/`tauri build` fail otherwise, and
`src/test/capabilities.test.ts` fails if the code that runs in a window imports a wrapper that
window is not granted. Note the `_`→`-` rule, that `deny-*` is off-limits, and that
`permissions/autogenerated/` is generated and ignored.
- `CLAUDE.md` Backend Structure (`CLAUDE.md:179-180`): the `file_viewer/` bullet's "residual
risk … the AppManifest lockdown spec closes it" becomes "closed by `build.rs`'s
`AppManifest`"; add one bullet for `build.rs` + `command_census.rs`.
- `file_viewer/mod.rs` module doc: one sentence that `file-viewer-*` is also the capability
glob, so labels are minted only here.
- `.gitignore`: `app/src-tauri/permissions/autogenerated/`.
### 3.6 Deliberately not done
- `build.removeUnusedCommands` stays off. It compiles out commands no capability grants
(`tauri-macros/src/command/handler.rs:92-142`), but only when the CLI sets
`REMOVE_UNUSED_COMMANDS`, so `cargo test` and `tauri build` would compile different handler
lists, and it matches on the function identifier rather than a `rename`. The §3.2 check gives
the same guarantee at build time without a second code path.
- No `permissions/*.toml` written by hand, no permission sets. Every grant is a literal
`allow-<slug>` string in a capability file, so the census tests stay a flat string compare.
- No `webviews` patterns: this app has one webview per window.
## 4. Migration risks
| Risk | Where it bites | How it is caught |
|---|---|---|
| A registered command is left out of `default.json` | Runtime: that feature's invoke rejected with `not allowed. Permissions associated with this command: allow-…` (release: `Command … not allowed by ACL`) | `build.rs` §3.2 fails the build before any binary exists; the vitest §3.3 fails locally |
| Typo in an `allow-*` string | Build | tauri's `validate_capabilities` and the §3.2 check, both at build time |
| Parser regression yields an empty command list | Would silently restore the unguarded state | §3.2 step 2 panics on empty; §3.4 asserts `acl-manifests.json` has the full set |
| Stale `permissions/autogenerated/<cmd>.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 selectively prunes only the files whose `<name>` is no longer a registered command, every build (never `remove_dir_all` — Decision 9); live files are left alone so the build settles; the directory is gitignored |
| A `deny-*` added "for the viewer only" | Denies the command for main too (global) | §3.2 rejects any bare `deny-*` |
| `file-viewer.json` grants a main-only command | The viewer window gains reach | §3.2 `expected_windows` assertion and §3.3 equality check |
| Main-window code imports a `viewer*` wrapper (or the viewer imports a main one) | Runtime denial in that window | §3.3 `M ∩ V = ∅` and the per-side set checks |
| A future window label that happens to match `file-viewer-*` | Inherits the viewer grants | Labels are minted only in `file_viewer/`; note in that module's doc comment |
| Existing census tests (`lib.rs:924`, `file_viewer/mod.rs:63`) | Fail as written once bare grants appear | Updated in the same commit (§3.4) |
| Capability grants and `build.rs` land in different commits | Bare grants without a manifest fail the build; a manifest without grants builds but denies every command at runtime | One commit carries `build.rs`, both capability files and the census-test updates (plan Task 2) |
| `gen/schemas/acl-manifests.json` churn in diffs | Noise | Accepted; it is the reviewable evidence of what got embedded |
| Frontend invoked via a computed name | Un-auditable | §3.3 fails on a non-literal `invoke` argument |
Dev vs release: no behavioural difference (§2.5), so `tauri dev` is a faithful rehearsal. The
only visible difference is the error text.
## 5. Testing and manual verification
Automated (all must be green):
- `cd app/src-tauri && cargo test --offline`: §3.4 tests plus the existing census tests.
- `cd app && npx vitest run src/test/capabilities.test.ts` and the full `npm run test`.
- `npx tsc --noEmit`, `cargo clippy --offline` clean, `npm run build`.
- Negative checks, each done once by hand and then reverted, to prove the checks bite:
(a) remove one `allow-*` from `default.json` → `cargo check --offline` fails naming the
command; (b) misspell one → fails naming it; (c) add `deny-check-docker` → fails; (d) move
`allow-viewer-read-file` into `default.json` → fails the `expected_windows` assertion;
(e) import `listProjects` from `lib/tauri-commands` in `src/viewer/ViewerApp.tsx` → vitest
fails; (f) temporarily make `registered_commands` return `Some(vec![])` → the build fails
on the empty-list rule rather than building an unguarded app.
Manual (`npm run tauri dev`, then a release `tauri build` on Linux for the AppImage):
1. Cold start: projects list, Docker status, settings, help all render (each is an app command
on the main window).
2. Open a terminal, type, resize, paste an image, drop a file — the terminal group.
3. Files tab: list, view, rename, upload, save-to-host; Backup.
4. Browser view: enable, pop out, resize, close pop-out; in the pop-out's devtools,
`window.__TAURI_INTERNALS__?.invoke("list_projects")` must reject (remote origin, no
capability) exactly as before this change.
5. File viewer: click a path → window opens, reads, "Choose" list when ambiguous, edits, saves,
conflict banner, live reload on an external change (that is `viewer_poll_file`); then in
the viewer's devtools run
`window.__TAURI_INTERNALS__.invoke("list_projects")` and
`…invoke("read_container_file", {...})` — both must reject with
`not allowed on window "file-viewer-1"`. From the main window's devtools,
`invoke("viewer_read_file")` must reject likewise.
6. Settings export/import, gateway, STT, web terminal, scheduler tabs — one action each.
7. Watch the app log for `not allowed` during the whole pass; any occurrence is a missed grant.
## 6. Open questions — resolved
1. **CI test steps: out of scope** (controller ruling). CI runs no test suite (§1). The
build-time check (§3.2) covers the security property on every merge because it runs inside
`tauri build`; the vitest half (§3.3) and the `cargo test` half (§3.4) only run on a
developer's machine. Recommendation to the user, not part of this change: add
`cd app && npm run test` and `cd app/src-tauri && cargo test` steps to
`build-app-preview.yml` after the dependency install.
2. **`Box::leak` in `build.rs`: acceptable** (controller ruling; either was allowed). Chosen
over a generated include file because it is two lines, needs no `OUT_DIR` plumbing, and the
build script exits immediately afterwards.
3. **No `webviews` patterns** (controller ruling). One webview per window; `windows` globs
only.
## Decisions made during review
Checked against the tree at `bf22910` (the completed viewer) and the tauri-build 2.6.0 /
tauri-utils 2.9.0 / tauri 2.11.0 sources pinned by `Cargo.lock`. The user-approved goal
(allow-lists only, one file per window, the build fails rather than a feature breaking at
runtime) is unchanged. What changed:
1. **Five viewer commands, not four (§3.1).** The implemented viewer polls with
`viewer_poll_file`, so `file-viewer.json` gets five `allow-viewer-*` grants and `default.json`
gets 110, for 115 registered commands.
2. **The window rule is a function (§3.2).** `expected_windows(command)` (`viewer_*` →
`file-viewer-*`, else `main`) replaces both the "no `viewer_*` in main / only `viewer_*` in
the viewer file" policy assertion and the `SHARED` list. A command that must be callable from
both windows is an edit to that function, which is the visible design change the earlier
draft wanted; the extra list was a second place to say the same thing.
3. **The check logic is a module, not inline `build.rs` (§3.2, §3.4).** A build script cannot
be unit-tested, and the empty-list, typo, `deny-*` and wrong-file rules are exactly the code
whose failure mode is "builds an unguarded app". `command_census.rs` is compiled into both
`build.rs` and the test build, so the rules get ordinary `cargo test` coverage and the
handler-list parser the census test already used becomes the one the build uses.
4. **The vitest test follows imports, not directories (§3.3).** The viewer never calls `invoke`;
it imports wrappers from the shared `lib/tauri-commands.ts`. So the test computes the
viewer's transitive import closure from `viewer/main.tsx` and takes the wrappers that closure
imports; a directory split would have put every viewer command in the "main" bucket and
proved nothing.
5. **Both census tests change, not one (§3.4).** The viewer feature added its own exact-set
test in `file_viewer/mod.rs`; the earlier draft only knew about the `lib.rs` one.
6. **`gen/schemas/*.json` is committed with the change.** It is tracked, tauri-build rewrites it
on every build, and after this change it contains the `__app-acl__` manifest and the
resolved grants — the reviewable evidence of what the binary embeds. Expect a large diff:
`desktop-schema.json` and `linux-schema.json` gain an enum entry per generated permission
(about 12 lines per command, ~2,700 lines across the two files for 115 commands).
9. **Stale-permission cleanup is selective, not `remove_dir_all` (§3.2 step 3).** Found by the
spike: wholesale regeneration would re-run the build script on every `cargo` call.
7. **Atomicity (§4).** A bare `allow-*` in a capability file with no app manifest is a build
error (`Permission allow-… not found`), and a manifest with no grants builds an app in which
every command is denied. `build.rs`, both capability files and the census-test updates are
therefore one commit; the shared module and its tests land before it, and the end-to-end
manifest test, the vitest and the docs after it.
8. **Spike (see §7).** Every claim in §2 that the design leans on was reproduced in a throwaway
worktree before the plan was written.
## 7. Spike results (2026-09-22, throwaway worktree at `bf22910`, then removed)
Environment: tauri-build 2.6.0 / tauri 2.11.0 / tauri-utils 2.9.0 from `Cargo.lock`,
`cargo check --offline` with the shared `target/` dir; each run ~3 s (only `triple-c`
recompiles). A fresh worktree needs `app/dist` to exist (`npm run build`) or
`generate_context!` panics on `frontendDist` before any ACL code runs.
| # | Experiment | Result |
|---|---|---|
| A | Untouched tree | Builds. `acl-manifests.json` has only plugin keys (`core`, `core:*`, `dialog`, `opener`); no `__app-acl__`. |
| B | `build.rs` with `AppManifest::new().commands(Box::leak(…))` for three names | Builds. `permissions/autogenerated/{check_docker,list_projects,viewer_read_file}.toml` written (`allow-check-docker` / `deny-check-docker`, `commands.allow = ["check_docker"]`, header `# Automatically generated - DO NOT EDIT!`). `acl-manifests.json` gains `__app-acl__` with the six identifiers. `desktop-schema.json` and `linux-schema.json` each gain 36 lines (enum entries). `capabilities.json` unchanged until a file references a grant. Nothing else written. |
| C | Bare `allow-check-docker`, `allow-list-projects` in `default.json`; `allow-viewer-read-file` in `file-viewer.json` | Builds. `gen/schemas/capabilities.json` shows the grants under each capability. |
| D | Bare `allow-does-not-exist` | **Fails** (exit 101): `Permission allow-does-not-exist not found, expected one of allow-check-docker, …` — one ~10 KB line listing every identifier, no fuzzy hint. Hence the project's own check with a readable message. |
| E | Typo `allow-check-dokcer` | Fails, same shape. |
| F | Bare `deny-check-docker` in `file-viewer.json` | **Builds** and appears in `capabilities.json` — tauri does not object; only §3.2's ban does. |
| G | Drop `list_projects` from the manifest list, keep its grant | **Builds**: the stale `list_projects.toml` is still globbed. After deleting `permissions/autogenerated`, fails with `Permission allow-list-projects not found`. Stale files are real. |
| H | Original `tauri_build::build()` plus one bare grant | **Fails**: `Permission allow-check-docker not found, expected one of core:default, …`. The message goes to the build script's **stdout** (`println!`). Capability grants cannot land before the manifest. |
| I | Rerun semantics | tauri-build emits `cargo:rerun-if-changed=capabilities` unconditionally (`acl.rs:427`) and `=permissions` when the dir exists (`acl.rs:300-305`). Editing a capability file alone re-runs the script and refreshes `gen/schemas`. Writing a `.toml` makes the *next* run re-run once more, then it settles because `write_if_changed` leaves unchanged files alone. |
| J | Signatures | `AppManifest::commands(mut self, commands: &'static [&'static str]) -> Self` (`acl.rs:100`); `AppManifest` is `Copy`, so no owned alternative — `Box::leak` it is. `try_build(Attributes) -> anyhow::Result<()>`; `build()` prints `{error:#}` and `exit(1)`. |
@@ -0,0 +1,404 @@
# Terminal file viewer/editor — design
Date: 2026-09-22
Status: approved in conversation (user); reviewed against the code 2026-09-22 (see "Decisions
made during review" at the end); plan at `docs/superpowers/plans/2026-09-22-terminal-file-viewer.md`
## Goal
File locations Claude prints in a terminal tab (`src/foo.ts:42`, `/workspace/x/README.md`,
`app/src/lib/urlRelay.ts:139-150`) become clickable. A click opens the file in its **own OS
window**, scrolled to and highlighting the line/range, syntax-highlighted, live-reloading while
Claude changes it, and **editable** so specs and similar files can be read, edited and saved
from inside the app.
Non-goals: tabs inside a viewer window, creating new files, diff/merge views, opening files in a
host editor, locking the viewer window out of every other app command (that is the follow-up
AppManifest spec, `2026-09-22-app-manifest-lockdown-design.md`).
## Current state (verified against the tree at `3537b23`)
- Versions: tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0, wry 0.55, Vite 6.4.1,
@xterm/xterm 5.5.0, @xterm/addon-web-links 0.12.0, React 19, bollard 0.18.1.
- `TerminalView.tsx` (props `{ sessionId, active }`; `projectId` is derived from the session in
the store): `WebLinksAddon` (http/https only, its own `registerLinkProvider` inside the addon),
OSC 8 via `linkHandler` (`createOsc8LinkHandler(getHost, readState)`; xterm's
`OscLinkProvider` drops any target that is not `http:`/`https:` **or that fails `new URL()`**
because `allowNonHttpProtocols` is unset), OSC 7777 URL relay. Nothing calls
`terminal.registerLinkProvider` directly. `opensOnClick(event, ctx, modifierPromised)` is a
module-private function in `TerminalView.tsx`; toasts are `useAppState.getState().pushToast`.
- `read_container_file(project_id, path, max_bytes)` in `commands/file_commands.rs` reads via
bollard `download_from_container` (`fetch_container_file`). **The archive endpoint does not
follow a final symlink and the helper refuses links** ("is a link — open its target instead"),
so any path the viewer reads must already be resolved to a regular file. It caps at
`MAX_READ_BYTES` = 8 MiB, returns `FileContents { contents_base64, truncated, size }`, and does
**not** call `require_running`. `validate_container_path` (absolute, no NUL, no `..` segment,
≤ 4096 bytes; it rejects rather than normalises) and `validate_container_write_path` (that plus
`is_under_root` against `CONTAINER_WRITE_ROOTS = /workspace, /home/claude, /tmp`, compared by
whole segments) are private `fn`s in `file_commands.rs`.
- Execs: the container user is addressed by **name**, `"claude"` (never `uid:gid`);
`docker::exec::exec_oneshot_streams_as(container_id, "claude", cmd, env) -> (stdout, stderr,
exit_code)` runs without stdin, cwd `/workspace`, 8 MiB output cap, lossy UTF-8. There is no
helper that feeds bytes to an exec's stdin. `ExecSessionManager::write_file_to_container
(container_id, file_name, bytes)` lands bytes at `/tmp/<file_name>` owned by the container
user (`container_user_ids`) with mode 0644 via the archive API. `upload_host_file_with_ids`
is the host-file variant of the same tar path. `resolve_container_dir` runs `realpath -m` as
`claude` and re-validates the result against the write roots (allowing the literal path when
`realpath` fails).
- `FileViewerModal` + `components/projects/home/filePreview.ts` (Files tab): `previewKind`,
`imageMimeFor`, `previewLimit`, `decodeBase64`, `looksBinary`, `TEXT_PREVIEW_LIMIT` 1 MiB,
`IMAGE_PREVIEW_LIMIT` 5 MiB. Reuse the classification and limits.
- Only second window today is the browser-view pop-out (`browser_view/popout.rs`), a
remote-origin window with no capability and no IPC. It builds `WebviewWindowBuilder::new(app,
&label, WebviewUrl::External(url))` straight from an **async** command (no
`run_on_main_thread`; Tauri documents that windows must be created from async commands, not
sync ones), hooks `window.on_window_event` for `Destroyed`, and closes with `destroy()`, never
`close()`. `lib.rs`'s `on_window_event` returns early for any label but `main`.
- `build.rs` is a bare `tauri_build::build()`: **every app command is callable from every local
window**. `capabilities/default.json` gates plugin commands only and lists `windows: ["main"]`.
App commands need no capability entry (CLAUDE.md, "Key Conventions"). *(Historical snapshot at
`3537b23`. Closed 2026-09-22 by the AppManifest lockdown
(`2026-09-22-app-manifest-lockdown-design.md`) — see §6 below.)*
- The frontend does not know a terminal's cwd. Terminal execs start in `/workspace`; each project
path is bind-mounted at `/workspace/<mount_name>` (`ProjectPath { host_path, mount_name }`,
rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a
mount.
- Tauri multi-window facts that the design rests on:
- `WebviewUrl::App("viewer.html".into())` is `Url::join`ed onto `build.devUrl` in dev
(`http://localhost:1420/viewer.html`) and onto `tauri://localhost/` in a bundle
(`http://tauri.localhost/` on Windows); both are `Origin::Local`, so capability files apply.
**If `viewer.html` is missing, both Vite's dev server and Tauri's asset lookup silently fall
back to `index.html`** — the main app opens in the viewer window. A test guards against this.
- Capability `windows` entries are `glob::Pattern`s, so `"file-viewer-*"` matches.
- `getCurrentWindow().onCloseRequested(cb)` listens on `tauri://close-requested` and then calls
`destroy()` itself; Rust calls `prevent_close()` whenever a JS listener exists. So the
viewer needs `core:window:allow-destroy` or **the X button stops working** the moment the
listener is registered. `listen`/`unlisten` need `core:event:allow-listen`/`allow-unlisten`.
Rust→window emits need no grant on the receiving side.
- `app.emit_to(label, …)` targets one label, but a bare `listen()` in the main window
(`EventTarget::Any`) still receives it. The viewer listens through
`getCurrentWindow().listen(...)`, and the main window never listens to viewer event names.
- The `app.security.csp` applies to every `.html` Tauri serves, `viewer.html` included. Tauri
adds a `'nonce-…'` to `style-src` only when the entry HTML contains a literal `<style>`
element, and a nonce disables `'unsafe-inline'` — which CodeMirror's `style-mod` needs for its
runtime `<style>` injection. **`viewer.html` must not contain an inline `<style>`.**
## Design
### 1. Path detection in the terminal (`app/src/lib/filePathLinks.ts`, `TerminalView.tsx`)
- A pure matcher `findFilePathLinks(lineText) -> FilePathMatch[]`, where
`FilePathMatch = { start, end, path, line?, col?, endLine? }` (`start` inclusive, `end`
exclusive, string indices into `lineText`), used by a new `ILinkProvider` registered with
`terminal.registerLinkProvider` right after `term.loadAddon(webLinksAddon)` (providers are
asked in registration order; the web-links one runs first, so an `http://` span never reaches
the file matcher as a candidate — the matcher additionally refuses any span overlapping `://`).
- Matches tokens that look like paths **with a file extension** (or a known extensionless name:
`Makefile`, `Dockerfile`, `CLAUDE.md`-style names are already covered by the extension rule),
optionally absolute, optionally `./`/`../` prefixed, followed by optional `:line`,
`:line:col`, or `:start-end`. Also accepts `#L42` / `#L40-L50` suffixes.
- Strips wrapping that Claude's markdown rendering leaves: backticks, parentheses, brackets,
quotes, trailing `.,;:` punctuation.
- Must not match inside URLs (http links are already handled; skip spans overlapping `://`),
bare version numbers (`1.2.3`), or domain names (`example.com` with no `/` — require either a
`/` in the token or an extension from an allowlist of common source/doc extensions for
slash-less tokens).
- Wrapped rows: v1 joins a buffer row with its `isWrapped` continuation rows the way
`WebLinksAddon`'s private `LinkComputer._getWindowedLineStrings` does (walk up while the row
`isWrapped`, walk down while the next row `isWrapped`, 2048-char budget, `translateToString
(true)`), then maps string indices back to `{x, y}` cells. `LinkComputer` is not exported from
the built addon, so the walk is re-implemented in `app/src/lib/xtermLineJoin.ts`
(`joinWrappedRows(buffer, rowIndex0) -> { text, firstRow0, rowStarts }` plus
`stringIndexToCell`). `urlDetector.ts` works on the byte stream, not the buffer, so it has
nothing reusable here. `ILink.range` is 1-based on both axes with an **inclusive** end column;
`provideLinks(bufferLineNumber)` is 1-based and `buffer.active.getLine(y)` is 0-based.
- OSC 8 `file://` targets: set `allowNonHttpProtocols: true` on the handler and dispatch by
scheme in `createOsc8LinkHandler`. With the flag on, xterm hands **every** OSC 8 target to
`activate`/`hover`, including unparseable ones and `javascript:`; so the handler parses with
`new URL()` itself: `file:``onOpenFile(pathname decoded, no line)`, `http(s):` → the
existing `sanitizeRelayUrl``openUrlExternal` path unchanged, anything else → refused
(`console.warn`, nothing opened). A refused target (`javascript:`, any other scheme,
unparseable text) keeps the existing refusal card ("This link will not be opened — it failed
the URL safety check"), which never echoes the target into the DOM; this is the ruled
behaviour (Task 9), not a new hover card. The hover card for a `file:` target shows the path and "Open
in viewer". The `WebLinksAddon` comment that describes the old `allowNonHttpProtocols`
behaviour is updated, not left stale.
- Activation uses the same click gating as web links (`opensOnClick`: no selection drag,
single click; `modifierPromised` for OSC 8 hovers). A click invokes
`openFileViewer(projectId, rawPath, line, col, endLine)``open_file_viewer`. No
confirmation toast — nothing leaves the app and nothing is written without an explicit save.
A rejected open (cap, container not running, project gone) is a `pushToast({ kind: "error" })`.
- Hover: underline + pointer cursor (`decorations`), and the same bottom-left card the OSC 8
handler draws, reading "Open in viewer".
### 2. Path resolution (Rust, `commands/file_viewer_commands.rs` + `file_viewer/resolve.rs`)
- Pure candidate generation `candidate_paths(raw, mount_names) -> Result<Vec<String>, String>`:
- Absolute: exactly one candidate, after `validate_container_path` (which rejects `..`).
- Relative `p`: strip a leading `./`, collapse repeated `/`, reject any `..` segment or NUL or
length > 4096; candidates in order `/workspace/<p>`, then `/workspace/<mount>/<p>` for each
non-empty `mount_name`, de-duplicated. Relative paths are the common case (Claude prints
project-relative paths) and `/workspace/<p>` is first because the terminal's exec cwd is
`/workspace`.
- Existence is checked in **one** exec as `claude`: `sh -c` with the candidates as `$1..$n`,
printing `realpath -e -- "$c"` for each candidate that is a regular file (`test -f`, which
follows symlinks). Output lines are the **resolved** paths, which is what the registry stores
(`fetch_container_file` refuses a link, so the resolved path is the only one that reads). The
resolved path is re-checked with `validate_container_path`; a candidate whose resolution
escapes to an invalid path is dropped. Candidate lists are capped at 16 entries; output lines
are de-duplicated (two candidates may resolve to one file).
- 0 matches → window opens in a "not found" state listing the candidates tried.
- 1 match → open it.
- \>1 match → window opens in a "choose file" state listing matches; choosing one calls
`viewer_choose_file(index)`; the choice is from the Rust-held candidate list, never a path from
the window.
- Container must be running (`require_running`, via `project.container_id`); otherwise error
toast in the main window, no window opened.
### 3. One window per click (`src-tauri/src/file_viewer/{mod.rs,registry.rs,window.rs}`)
- Each successful `open_file_viewer` creates a `WebviewWindow` with label
`file-viewer-<counter>` (monotonic `AtomicU64`, never reused within a process), loading the
app's own bundle at a second Vite entry (`viewer.html``src/viewer/main.tsx`;
`build.rollupOptions.input = { main: "index.html", viewer: "viewer.html" }`), title
`<basename> — <project name>` (set from Rust), default 900×700, min 480×320, resizable. The
command is `async` (Tauri's requirement for window creation from a command).
- A registry `ViewerRegistry { entries: Mutex<HashMap<String, ViewerTarget>>, next: AtomicU64 }`
managed with `app.manage(...)` (separate from `AppState`, like the browser view keeps its own
state), where
`ViewerTarget { project_id, project_name, state: Resolved{container_path} | Choose{candidates}
| NotFound{tried}, initial: Location { line, col, end_line } }`.
Entry removed on `WindowEvent::Destroyed`.
- If a window for the same `(project_id, container_path)` is already open (Resolved entries
only): `unminimize()`, `set_focus()` and `app.emit_to(label, "file-viewer-goto", Location)` to
that window instead of opening a duplicate; the viewer listens with
`getCurrentWindow().listen("file-viewer-goto", ...)`.
- Cap: 20 viewer windows (`MAX_VIEWER_WINDOWS`); the 21st click returns an error the main window
shows as a toast. The cap counts registry entries, and an entry is reserved **before** the
window is built so two concurrent clicks cannot both pass the check.
- When the project's container stops/is removed, open viewer windows stay open but their reads
fail and they show a "container not running" banner; they are not force-closed (unsaved text
must not be destroyed). The registry stores `project_id`, not `container_id`, so a recreated
container is picked up on the next poll.
- `lib.rs`'s shutdown teardown does nothing for viewers: the process exits and the OS closes
them. Unsaved edits in a viewer are lost on app quit (accepted; it is what the main window's
close does to a terminal too).
### 4. Editor (`app/src/viewer/`)
- CodeMirror 6, direct packages only (no `codemirror` meta-package, no
`@codemirror/language-data` — it hard-depends on 13 more grammars, one of which,
`legacy-modes/mode/pug`, calls `Function(...)`): `@codemirror/state`, `view`, `commands`,
`search`, `language`, `lang-markdown`, `lang-javascript`, `lang-rust`, `lang-python`,
`lang-json`, `lang-yaml`, `lang-css`, `lang-html`, `legacy-modes` (toml, shell via
`StreamLanguage.define`), plus `@lezer/highlight` for the tag-based highlight style. Language
packs are chosen by extension in `viewer/languages.ts` and loaded with dynamic `import()`
(same-origin chunks, fine under `script-src 'self'`). Verified: none of these packages uses
`eval`/`new Function`/Workers; `style-mod` injects a `<style>` element, allowed by
`style-src 'unsafe-inline'`. No CSP change.
- Theme: `EditorView.theme` + `HighlightStyle` in `viewer/viewerTheme.ts`, colours from the
app's CSS custom properties (`index.css` is imported by the viewer entry so the tokens exist).
- Line numbers, search (Ctrl/Cmd+F, `searchKeymap`), go to line (`gotoLine`), history,
`indentWithTab`, `lineWrapping` for markdown/plain text, highlight of the target line/range
(a `Decoration.line` `StateField` driven by a `setHighlight` `StateEffect`), scroll target
into view centred (`EditorView.scrollIntoView(pos, { y: "center" })`) on open and on
`file-viewer-goto`.
- Header: container path, project name, state badges (Read-only reason / Unsaved / Saved /
Changed on disk / Container not running), Save button.
- **Editable** only when: text (per `filePreview.ts` classification and `looksBinary`), not
truncated (≤ 1 MiB), and Rust says `editable: true` (the resolved path is inside
`CONTAINER_WRITE_ROOTS`). Otherwise the editor is read-only (`EditorState.readOnly` +
`EditorView.editable` both false) with a badge stating why. Images render read-only as in
`FileViewerModal` (Blob + object URL, `IMAGE_PREVIEW_LIMIT`).
### 5. Save + live reload
- Content identity: SHA-256 hex of file bytes. Two producers, one definition — the hash of the
file's full bytes: Rust computes it (`sha2`, already a dependency) over the bytes it fetched,
and `sha256sum` inside the container computes it for polls and for the save check. The two
agree exactly when the read was not truncated, which is the only case in which the hash is
used as a save base.
- `viewer_read_file(max_bytes)` → `ViewerFile { contents_base64, truncated, size, hash,
editable, readonly_reason: Option<String> }` for the caller's own target (`Resolved` only).
`hash` is over the returned bytes. `editable` is `validate_container_write_path(resolved)`
succeeding; `readonly_reason` is its message otherwise.
- `viewer_poll_file()` → `ViewerPoll { exists, hash: Option<String>, size: Option<u64> }` via one
exec as `claude`: `sha256sum -- "$1"` + `stat -c %s -- "$1"`. Missing file → `exists: false`.
This is one small exec per window per tick, not a 1 MiB archive download.
- Poll every 2 s while `document.visibilityState === "visible"`; pause otherwise; poll once
immediately on `visibilitychange` back to visible. The reducer tracks `diskHash` (last known
full-file hash; seeded from the read's `hash` when `truncated` is false, otherwise from the
first poll) and `baseHash` (the hash the buffer was loaded from).
- Poll hash == `diskHash` → nothing.
- Poll hash changed, editor clean → `viewer_read_file` again, replace document, preserve
scroll position and cursor (clamped), brief "Reloaded" indicator.
- Poll hash changed, editor dirty → banner "Changed on disk" with **Reload (discard mine)** and
**Overwrite on save**. No auto-merge. "Overwrite on save" sets `baseHash` to the polled hash
so the next save succeeds.
- `exists: false` → banner "File no longer exists"; content kept, editable text retained so the
user can copy it; saving is disabled (no file creation).
- Poll error (container not running) → banner "Container not running", editor keeps its state,
polling continues so the banner clears when the container is back.
- Save: Ctrl/Cmd+S or Save button → `viewer_write_file(contents_base64, base_hash)` → `Ok(hash)`.
- Mechanism (**all mutation of the target directory happens as the container user**; the
Docker archive API, which writes as root, only ever lands the payload in `/tmp`):
1. Rust: `Resolved` entry only; decode; refuse if `!editable`, if content > 1 MiB, or if
`base_hash` is not 64 hex chars.
2. `ExecSessionManager::write_file_to_container(container_id, "triple-c-viewer-<uuid>",
bytes)` → `/tmp/triple-c-viewer-<uuid>`, owned by the container user, mode 0644.
3. One `sh` script as `claude` (`exec_oneshot_streams_as`), args `target tmp base_hash`:
`test -f target` else exit 4 (gone); `sha256sum target` ≠ `base_hash` → exit 3
(conflict); if the directory is writable: `cp tmp dir/.<name>.triple-c-tmp`, `chmod
--reference=target staged`, `mv -f staged target`; else `cat tmp > target` (in-place
fallback for a writable file in a read-only directory); `rm -f tmp` in every branch
(`trap`); print `sha256sum target` on success.
4. Exit 3 → `Err("conflict: …")` (the window shows the "Changed on disk" banner); exit 4 →
`Err("gone: …")`; other non-zero → the clipped stderr.
- Ownership: a non-root user cannot `chown`, so the file ends up owned by the container user
— the same thing Claude Code's own edits produce. Mode is preserved via `chmod --reference`.
- Symlinks: the registry already holds the `realpath -e` target (§2), and `editable` was
computed on it, so a link into a non-writable root is read-only.
- Returns the new hash; editor becomes clean, `baseHash = diskHash = new hash`.
- Closing with unsaved edits → the window's own confirm bar (Save / Discard / Cancel) driven by
`onCloseRequested` (`event.preventDefault()` when dirty); Discard calls `destroy()`.
### 6. Security
- New capability file `capabilities/file-viewer.json`, `windows: ["file-viewer-*"]`, granting
exactly: `core:event:allow-listen`, `core:event:allow-unlisten`, `core:window:allow-destroy`
(required for `onCloseRequested` to be able to close the window at all — see "Current state"),
and `core:webview:allow-internal-toggle-devtools` (dev-only, as in `default.json`). No
`allow-close`, no `set-title`/`set-focus`/`is-minimized` (title and focus are set from Rust;
polling pauses on `visibilityState`). `default.json` stays `windows: ["main"]`. Update the
threat-model census in `default.json`'s description to name the second file and why the viewer
gets `allow-destroy`.
- Viewer commands take `window: tauri::Window` and check the label:
- `open_file_viewer` — `window.label() == "main"` only.
- `viewer_get_state`, `viewer_read_file`, `viewer_poll_file`, `viewer_write_file`,
`viewer_choose_file` — `file-viewer-*` only, and they operate solely on the registry entry
for **the caller's own label**. No viewer command accepts a path or a label as an argument.
- Rendering: CodeMirror renders text as DOM text nodes; nothing uses `innerHTML` /
`dangerouslySetInnerHTML` on file content. CSP unchanged.
- Closed by the AppManifest follow-up (`2026-09-22-app-manifest-lockdown-design.md`, implemented):
`build.rs` now declares a Tauri `AppManifest` from `generate_handler!`, so a compromised viewer
window can invoke only the five `allow-viewer-*` app commands granted in
`capabilities/file-viewer.json`, not any other app command.
- Update CLAUDE.md (frontend/backend structure, Key Conventions note about the viewer window and
label-gated commands).
### 7. Error handling
All Rust errors are sentences suitable for display. Container-authored text is clipped via
`clip_container_text` (made `pub(crate)`). The main window shows `open_file_viewer` failures as
toasts; the viewer shows read/write failures as banners and never loses the editor buffer on
failure.
### 8. Testing
- Vitest: `filePathLinks` matcher (positive/negative table incl. markdown wrapping, URLs,
versions, `:l:c`, ranges, `#L`), `xtermLineJoin` on a fake buffer, OSC 8 scheme dispatch (in
`TerminalView.test.tsx`, which already tests `createOsc8LinkHandler`), the viewer
reload/dirty/conflict state machine as a pure reducer (`viewer/viewerState.ts`), editability
rules, language selection.
- Rust unit tests: candidate generation and normalization, label gating, registry
dedupe/cap/removal, write script argument shape, size cap, `viewer.html` presence + Vite
input assertion, and `every_command_is_registered_exactly_once` (existing) for the new
commands.
- `npx tsc --noEmit`, `npx vitest run`, `cargo test`, `cargo clippy` clean.
- Manual (`npx tauri dev`): click paths in Claude output, multiple windows, edit + save,
edit while Claude edits the same file (conflict banner), container stop with window open.
## Decisions made during review
Facts were checked against tauri 2.11.0 / tauri-utils 2.9.0 / @tauri-apps/api 2.11.0 sources,
xterm 5.5.0, Vite 6.4.1 and the tree at `3537b23`. The user-approved decisions (one window per
click, editable, CodeMirror 6, probe-roots resolution, 2 s polling, no autosave, conflict banner,
20-window cap, AppManifest deferred) are unchanged. What changed:
1. **Write mechanism chosen (§5).** No exec helper feeds stdin, and whether a half-close of the
hijacked exec connection reaches the process is unverified. Instead: stage bytes at `/tmp`
with the existing `write_file_to_container` (container-user-owned), then one `sh` script as
`claude` does hash-check + `chmod --reference` + `mv -f` in the target directory. Every write
into the target directory is therefore subject to the container user's permissions; the
root-privileged archive API never touches it. `chown --reference` was dropped: a non-root
exec cannot chown, so the spec now says the saved file is owned by the container user.
2. **Polling is an exec (`sha256sum` + `stat`), not a re-download (§5).** A 2 s tick per window
that streams a 1 MiB archive was the alternative. The full-file hash from `sha256sum` equals
the Rust-side hash exactly when the read was untruncated, which is the only editable case;
the reducer keeps `diskHash` separate from `baseHash` so read-only (truncated) files still
detect change without a reload loop.
3. **Resolution stores the `realpath -e` result (§2).** `fetch_container_file` refuses a
symlink, so the raw candidate path could not be read; probing already needs an exec, so it
resolves in the same one. Candidate normalisation *rejects* `..` (matching
`validate_container_path`, which does not normalise) rather than resolving it.
4. **Exact capability set (§6).** `onCloseRequested` in @tauri-apps/api 2.11 calls
`this.destroy()`, and Rust `prevent_close()`s whenever a JS listener exists, so
`core:window:allow-destroy` is mandatory; `allow-close` is not needed and not granted.
Focus/title/unminimize are done from Rust, so no `core:window:allow-set-*` grants.
5. **OSC 8 with `allowNonHttpProtocols: true` receives unparseable and `javascript:` targets
(§1).** The handler now parses and dispatches itself, and refuses everything but `file:` and
`http(s):` before drawing a file or web hover card; a refused target gets only the existing
refusal card, which never echoes the target. The stale comment in the `WebLinksAddon` branch is
updated as part of the change.
6. **Wrapped-row joining is re-implemented (§1).** `WebLinksAddon`'s `LinkComputer` is not
exported from the built package and `urlDetector.ts` never touches the buffer.
7. **CodeMirror package list pinned (§4).** Direct packages only; `@codemirror/language-data`
and the `codemirror` meta-package are excluded (extra grammars, one with `Function(...)`).
A home-grown theme from CSS tokens rather than `theme-one-dark`.
8. **`viewer.html` fallback trap (Current state, §8).** Both Vite dev and Tauri's asset lookup
fall back to `index.html` when the entry is missing; a Rust test asserts the file and the
Vite input entry exist.
9. **Registry is its own managed state (§3)** rather than a field on `AppState`, with a
reserve-before-build cap check; label counter is a process-wide `AtomicU64`.
10. **Event delivery (§3).** `emit_to(label, …)` still reaches a bare `listen()` in the main
window, so the viewer subscribes via `getCurrentWindow().listen` and the main window has no
listener for viewer event names.
## Manual verification checklist
Not runnable inside the planning/implementation container: it needs `npx tauri dev` on a machine
with a display, plus a running project container. Run this after every task on this feature has
landed, including any review fix rounds (in particular Task 11's CRLF/BOM save fix, item 16
below).
- [ ] 1. In a Claude session, ask for a file listing; click `src/…` paths, `/workspace/...`
absolute paths, a `path:line` and a `path:start-end`. Each opens its own window titled
`<basename> — <project>`, scrolled to and highlighting the line/range.
- [ ] 2. Click the same path again → the existing window is focused and re-highlights; no
duplicate.
- [ ] 3. Open 20 windows; the 21st click shows the "20 file windows are already open" toast.
- [ ] 4. Edit, `Ctrl+S`: file changes in the container (`cat` it in a bash tab); mode preserved
(`stat -c %a`); owner is the container user.
- [ ] 5. While a window is open, have Claude edit the file: clean window reloads with "Reloaded";
a dirty window shows "Changed on disk" with both buttons; "Overwrite on save" then Save
succeeds; "Reload (discard mine)" drops the edits.
- [ ] 6. Save while the file changed between polls → "Changed on disk" banner, no data written.
- [ ] 7. Delete the file in a bash tab → "File no longer exists", text still copyable, Save
disabled.
- [ ] 8. Stop the project with a window open → "Container not running" banner; start it → banner
clears, polling resumes.
- [ ] 9. Click a path under `/etc` or a `file:///etc/hosts` OSC 8 link → read-only badge with the
write-roots reason.
- [ ] 10. Close a dirty window with the X → Save / Discard / Cancel bar; Cancel keeps it open;
Discard closes.
- [ ] 11. Close the *main* window with viewers open → app exits, viewers close.
- [ ] 12. Ctrl/Cmd+Shift+I opens devtools in a viewer in dev; in a release build the CSP console
shows no violations (CodeMirror styles apply).
- [ ] 13. A wrapped long path (narrow the terminal) underlines across the wrap and opens.
- [ ] 14. Image (`.png`) opens read-only; a `.bin`/binary shows "not text".
- [ ] 15. **File-path hover key-hint wording.** Hover a plain-text file-path link in the terminal
(not an OSC 8 hyperlink) while a foreground program is holding the mouse (e.g. an
interactive TUI like `vim`/`htop`/`claude`'s own REPL) and confirm the hover card's key hint
reads correctly for the platform/mode: "Shift+click to open" on non-Mac while the program
tracks the mouse, "Click to open" when nothing tracks the mouse, "Option+click to open" on
Mac with `macOptionClickForcesSelection`, or "Not clickable while a program holds the mouse"
on Mac without it (`openHintLabel` / `fillFileCard` in `TerminalView.tsx`). Then confirm the
*click itself* is held to whatever the card promised — the modifier actually required to
activate the link matches the hint shown, even if the program changes its mouse-tracking
mode between the hover and the click.
- [ ] 16. **CRLF/BOM file round-trip save.** Open a file in the container that has Windows line
endings (CRLF) and/or a UTF-8 BOM (e.g.
`printf '\xEF\xBB\xBF\r\nfoo\r\nbar\r\n' > /workspace/<project>/crlf.txt` in a bash tab),
open it in the viewer, make a small text edit, and Save. Then `cat -A` (or `xxd`) the file
in the container and confirm the CRLF line endings and the BOM are still present/unchanged
apart from the edit — i.e. the save did not silently normalize them to LF or strip the BOM.