Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion docs/specs/auto-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The standalone app checks for updates on launch, where the network policy allows

## How it works

**Must read and clear the post-install marker on launch** (§localStorage) and show its banner; a reported failure suppresses this launch's check. Otherwise wait 5 seconds, then read the network policy with `networkPolicy` over the Burrow link and, where it allows (`docs/specs/remote-network.md` → "Updates"), `check()` — no update is silent, an update raises the approval prompt; then the reminder, if due: `check-due`, recording `remindedAt`. **The reminder is re-evaluated hourly while the app runs**, reading no policy and never checking; **never over an undismissed notice, nor while the clock reads before 2026-09**, not yet set. Version-lookup and check failures are logged. **Only approval starts the background `download()`**; a failed one is logged and the prompt returns.
**Must read and clear the post-install marker on launch** (§localStorage) and show its banner; a reported failure suppresses this launch's check. Otherwise wait 5 seconds, then read the network policy with `networkPolicy` over the Burrow link and, where it allows (`docs/specs/remote-network.md` → "Updates"), `check()` — no update is silent, an update raises the approval prompt; then the reminder, if due: `check-due`, recording `remindedAt`. **Must skip that `check()` once an update is approved**, including through Check now during the wait or the policy read. **The reminder is re-evaluated hourly while the app runs**, reading no policy and never checking; **never over an undismissed notice, nor while the clock reads before 2026-09**, not yet set. Version-lookup and check failures are logged. **Only approval starts the background `download()`**; a failed one is logged and the prompt returns.

**Check now** — the `check-due` and `check-failed` links, and the `updates` port — shows `checking`, then `available`, `up-to-date`, or `check-failed`. **A second ask joins the check in flight. An update already approved is shown again, `downloading` or `downloaded`, instead of checked for**, which would offer it for approval twice. **Every successful check, automatic or asked for, records `checkedAt`** (§localStorage).

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/security-remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ per-Burrow browser storage follows `docs/specs/remote-security-model.md` ->
- **FAIL IF** `relay/src/state.ts` stops creating `$DORMOUSE_STATE_DIR` mode `0o700`, or stops writing every file through `writeAtomic` at mode `0o600`. The "every file" clause is a negative search over `relay/src/`: no `writeFile`, `appendFile`, or `createWriteStream` may target the state directory outside `writeAtomic`. A cheap default, not a cross-platform guarantee; the installer's directory permissions below protect the installed Relay's state (rationale).
- **FAIL IF** `FileBurrowStateStore` (`lib/src/host/remote/burrow-state-store.ts`) stops creating its directory `0o700` and writing `0o600` on non-Windows platforms, or if `VsCodeBurrowStateStore` stops keeping the **enrollment** in `SecretStorage`. The ACL's home in `globalState` is deliberate and is not a finding; the enrollment's is what carries `burrowToken`.
- **FAIL IF** a credential the Host→Burrow rename retired stops being deleted unread at boot: `state/hosts.json` on the Relay (`forgetRetiredState` in `relay/src/state.ts`, called from `relay/src/start.ts`), `remote-host.json` on a Node-resident Burrow (`forgetRetiredState` in `lib/src/host/remote/burrow-state-store.ts`, called from `sidecar-entry.ts`), and `dormouse.remote-host.enrollment`, `dormouse.remote-host.acl.*`, `remote-host.peer-token` in VS Code (`vscode-ext/src/retired-state.ts`, called from `activate()`). Each held a live `burrowToken` or peer secret, and `SecretStorage` cannot be enumerated — a key nothing removes *by name* outlives every build that knew it. Pinned by `relay/test/state-records.test.mjs`, `lib/src/host/remote/burrow-state-store.test.ts` and `vscode-ext/test/retired-state.test.ts`.
- **FAIL IF** `burrow_state_dir` in `standalone/src-tauri/src/lib.rs` stops calling `restrict_to_owner` on the state directory **before** spawning the sidecar — on Windows those Node modes are no-ops and Node cannot set an ACL, so the guarantee is held one layer down. That call carries both legs: a newly written enrollment file *inherits* the owner-only entry, and one a prior version already left under the `%LOCALAPPDATA%` ACL — with a live `burrowToken` in it — has that entry *propagated* onto it, the half `restrict_to_owner_leaves_one_owner_only_ace` covers with its pre-existing `before.json`.
- **FAIL IF** `burrow_state_dir` in `standalone/src-tauri/src/lib.rs` passes the sidecar a state directory `restrict_to_owner` did not lock — on Windows those Node modes are no-ops and Node cannot set an ACL, so the guarantee is held one layer down; a refusal keeps the Burrow in memory (`burrow_directory_permission_failure_disables_durable_state`). That call carries both legs: a newly written enrollment file *inherits* the owner-only entry, and one a prior version already left under the `%LOCALAPPDATA%` ACL — with a live `burrowToken` in it — has that entry *propagated* onto it, the half `restrict_to_owner_leaves_one_owner_only_ace` covers with its pre-existing `before.json`.
- **FAIL IF** `relay/src/start.ts` stops obtaining the setup password from `SetupPasswordStore.loadOrCreate(generateSetupPassword)`, `generateSetupPassword` stops using `crypto.randomBytes(32)`, `readConfig` reads `DORMOUSE_SETUP_PASSWORD` or any other setup-password input, or `SetupPasswordStore` stops refusing a persisted or generated value outside 64 lowercase hexadecimal characters. Pinned by `relay/test/config.test.mjs` and `relay/test/setup-password-store.test.mjs`.
- **FAIL IF** `createApp` accepts anything but 64 lowercase hexadecimal characters as the setup password injected by the entrypoint; pinned by `relay/test/app.test.mjs`.
- **FAIL IF** any installer stops making `config/`, `state/`, and `config/relay.env` reachable only by the installing user — the effective property `manage verify` tests: no principal other than that user may appear in the effective permissions. macOS and Linux achieve it with `0700`/`0600` under `umask 077`; Windows with a single owner-only ACE, whether the path carries it directly or inherits it from an already-locked parent. The Windows and Linux installers create `relay.env` and lock it before writing its contents (rationale).
Expand Down
48 changes: 28 additions & 20 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ constants in `lib/src/lib/platform/types.ts` (and `standalone/sidecar/pty-core.j
`#[tauri::command]` over an `async fn`, which the guard below accepts equally.
Tauri runs a *sync* command on the main thread, where the `recv_timeout` inside
`request_from_sidecar` / `request_from_sidecar_timeout` stops the webview painting
for the whole round trip, up to `AGENT_BROWSER_TIMEOUT` (30s) (rationale). **The
for the whole round trip, up to `BROWSER_REQUEST_TIMEOUT` (40s) (rationale). **The
three clipboard readers included**: their non-Windows branches round-trip through
the sidecar, and the declaration is per command, not per branch. A unit test in
`lib.rs` scans the source and fails on any command that reaches the blocking
Expand Down Expand Up @@ -574,8 +574,12 @@ checks in the debounce flush.
- **The flush slot is released in the same step as the drain.** A `Moved` landing
between the two was marked dirty with no thread left to write it — and that
move is exactly a window's final position.
- **Must recheck the save refusal under the journal lock before writing
geometry.** The flush reads the window and rect first; a close in between
removed the file (§Per-window close), and the write would put it back
(`a_geometry_flush_captured_before_close_cannot_recreate_removed_geometry`).

Source of truth: `CachedRect` / `GeometryState` / `note_geometry` /
Source of truth: `CachedRect` / `GeometryState` / `note_geometry` / `write_open_window_geometry` /
`restore_windows` in `standalone/src-tauri/src/lib.rs`; the sequencing is pinned
by `the_geometry_flush_slot_is_released_with_the_drain`.

Expand Down Expand Up @@ -604,12 +608,12 @@ Source of truth: `CleanupGate` and `WindowEvent::Destroyed` in
**Closing a window with siblings alive ends that window alone**; only the last
window's close is the quit. Rust prevents the close and emits
`dormouse://window-close-requested`; the webview acks (a ~2 s watchdog closes it
anyway if that listener is dead), asks about *its own* running work, removes its snapshot, kills the PTYs it owns, and calls back
anyway if that listener is dead), asks about *its own* running work, attempts snapshot removal, kills its PTYs, and calls back
`close_window`.

- **A close is deliberate, so it removes the blob** — geometry
and temp sibling included — and the next launch does not reopen the window
(`docs/specs/transport.md` → "The governing rule").
- **Must attempt to remove the blob before killing this Window's PTYs** — geometry
and temp sibling included; successful removal prevents reopening
(`docs/specs/transport.md` → "The governing rule"). Removal failures are logged and close proceeds; an old snapshot may reopen.
- **It runs no agent-recovery capture**: nothing is coming back.
- **A cancelled close retires its watchdog's token and never reuses it**: the
next close on that window is a fresh seq, so a watchdog still sleeping on the
Expand All @@ -618,11 +622,13 @@ anyway if that listener is dead), asks about *its own* running work, removes its
- **It confirms on a pending download as well as on running work.** An approved,
downloaded update lives in this webview's memory, so closing the window throws
it away and nothing else can install it (`docs/specs/auto-update.md`).
- **The snapshot is removed before the kill**, and Rust refuses every later save
for that label, so a PTY exit's save cannot write it back. **Both close paths
- **Must refuse every later save for a closing label**, so a PTY exit cannot recreate its snapshot. **Both close paths
set that refusal** — the webview's own `remove_window_session`, and
`finish_window_close` for the ack-timeout path, where the webview never ran at
all. It is dropped when the webview is destroyed and can no longer save.
all. **Must keep that refusal for the process lifetime**, geometry writes
included: a save dispatched before `Destroyed` can reach the disk lock after
it, and no label is reused within a process
(`a_closed_window_refuses_saves_for_the_process_lifetime`).
- **`close_window` is the one Rust half both endings share** — a deliberate close
and a window whose last Workspace moved away (§Transfer) — because what
separates them is entirely what the webview did before calling it.
Expand Down Expand Up @@ -700,7 +706,7 @@ below reads that record rather than inferring itself from the suppression map.
`transfer_workspace` / `open_workspace_window`. **Must return preparation refusals as `{ moved: false, reason }` without changing ownership.** On `Ok` it marks the Workspace
**transferring**: the Wall stays mounted, nothing is
released, and `getWindowSnapshot` omits it.
2. **Rust** reassigns `terminalIds` to the target, keeps routing their output to
2. **Rust** journals the arrival (below), then reassigns `terminalIds` to the target, keeps routing their output to
the source, and asks the sidecar to stamp a `pty:marked` line per id; at that
line the id's suppression begins, until its replay has been emitted to the
target. The source serializes each buffer at its mark and invokes
Expand Down Expand Up @@ -796,14 +802,17 @@ below reads that record rather than inferring itself from the suppression map.
- **A boot's `pty_request_init` excludes every id an arrival claims.** Ownership
moves at the invoke, so those shells would otherwise be listed as top-level
panes beside the Workspace about to mount them.
- **`begin_arrival` records the arrival in `sessions/arrivals.json`** — a JSON
array of `{ workspaceId, from, to, workspace, settled }`, never an entry in
either window's snapshot (rationale); the tombstone rules below read `settled`. **Must retain an adopted record until target
- **`begin_arrival` records the arrival in `sessions/arrivals.json` before
ownership moves** — a JSON array of `{ workspaceId, from, to, workspace,
settled }`, never an entry in either window's snapshot (rationale). **A failed
write must refuse the move with nothing changed**; the write runs outside
`arrivals`, so admission is rechecked after it and a refusal withdraws the
record (`a_failed_arrival_journal_refuses_the_move_with_nothing_changed`); the tombstone rules below read `settled`. **Must retain an adopted record until target
and source snapshots both reflect the move**, marking it settled at
`adopt_done` and checking after each `save_session` or source-window close
(`adoption_keeps_the_journal_until_both_snapshots_are_durable`). **Must reverse
the durable destination on hand-back and retain the record until both
snapshots reflect the return** (`a_hand_back_is_recovered_in_the_source_before_its_next_flush`).
snapshots reflect the return** (`a_hand_back_is_recovered_in_the_source_before_its_next_flush`). Failed settlement-marker or hand-back writes are logged and do not block live adoption or return.
**Must tombstone settled arrivals into a deliberately closed Window until
both snapshots omit them**, including during boot recovery
(`closing_an_adopted_target_never_resurrects_either_copy`).
Expand Down Expand Up @@ -943,16 +952,15 @@ written.
- **The label is sanitized** so it cannot escape the directory.
- **Temp-then-rename**, so a crash cannot truncate the previous snapshot. The temp
file is fsynced before the rename and, on unix only, the sessions directory
*after* it (rationale).
*after* it, best-effort (rationale).
- **Window identity is implicit**: each command keys by the invoking
`tauri::Window`'s `label()`, so the frontend stays window-agnostic and every
window (`ws-2`, …) persists to its own file rather than rewriting a sibling's.
- No WAL to grow, and rewriting the same path bounds the on-disk size to one
blob (rationale).
- **The writer removes its own temp file on every error path**, so only a crash
can leave one behind.
- **A per-window close removes the blob, its temp sibling and its geometry**
(§Per-window close); nothing else deletes a snapshot but the boot merge
- Per-window cleanup follows §Per-window close; only the boot merge otherwise deletes a snapshot
(§Arrival queue).
- **Must sweep orphan session temp files once at boot** in the active sessions
directory and, for debug builds, the legacy `<app_data_dir>/sessions` directory.
Expand All @@ -975,7 +983,7 @@ owner-only first and each an empty string when it could not be:
`DORMOUSE_STATE_DIR` (the Burrow store, `app_data_dir`) and
`DORMOUSE_RECOVERY_DIR` (the recovery record, the state root — so a dev run's
record cannot reach the installed app). The browser-dev harness sets both to its
own per-run temp directory. Source of truth: `recovery_state_dir` in
own per-run temp directory. Source of truth: `prepare_owner_only_dir` / `recovery_state_dir` in
`standalone/src-tauri/src/lib.rs`.

**Must restrict the session store to the owner before any bytes are written**
Expand All @@ -985,7 +993,7 @@ own per-run temp directory. Source of truth: `recovery_state_dir` in
silent no-op, it applies a protected single-entry DACL instead (mechanism in its
doc comment). `burrow_state_dir` locks the sidecar's state directory with the
same call and relies on it reaching a file that already *existed*, which
`restrict_to_owner_leaves_one_owner_only_ace` pins (rationale). **Must abort a snapshot save if either permission change fails**, preserving the previous snapshot. The state-directory call remains nonfatal and logs a `WARNING` naming the path. Pinned by `session_permission_failures_preserve_previous_snapshot_without_writing_bytes` and `session_write_tightens_directory_and_existing_temp_file`.
`restrict_to_owner_leaves_one_owner_only_ace` pins (rationale). **Must abort a snapshot save if either permission change fails**, preserving the previous snapshot. **Must withhold a state directory whose restriction fails**, logging a `WARNING` naming the path; the Burrow store and recovery record then stay in memory (`burrow_directory_permission_failure_disables_durable_state`). Pinned by `session_permission_failures_preserve_previous_snapshot_without_writing_bytes` and `session_write_tightens_directory_and_existing_temp_file`.

**Boot + the synchronous-read constraint.** `getState()` is synchronous —
cold-start restore reads it before React mounts — but a Tauri `invoke` is async, so
Expand Down Expand Up @@ -1140,7 +1148,7 @@ asks before discarding a pending download (§Per-window close).
`deferred_quit_and_close_requests_wait_for_membership_then_run_once` in
`standalone/src-tauri/src/quit_state.rs`, and
`transfers_cannot_change_membership_after_close_or_quit_confirmation_begins` and
`begin_arrival_admits_under_the_arrivals_lock_before_queueing` in
`begin_arrival_journals_then_admits_under_the_arrivals_lock_before_queueing` in
`standalone/src-tauri/src/lib.rs`.
- **Must collect votes before killing any window's Sessions.** Confirmation
consumes its callback once; a noninteractive full-window progress overlay
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/standalone.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ stays the webview's throughout and no polling loop is needed.

**The WKWebView WAL measurement.** WKWebView stores `localStorage` as SQLite in WAL mode, and WebKit pins that WAL with a long-lived reader that never advances during a running session — so it is never checkpointed, and an external checkpoint is blocked by the same reader. Rewriting the multi-MB scrollback-bearing session blob on every save grew the WAL to ~1 GB within a few hours (recorded 2026-07); a days-long session made it pathological. The Rust file store that replaced it has no WAL and rewrites the same file each time.

**Why the sessions directory is fsynced after the rename.** Fsyncing only the temp file leaves the new name recoverable-but-absent after a power loss; the directory-entry fsync is what makes the rename itself durable. Windows has no equivalent concept, hence unix-only.
**Why the sessions directory is fsynced after the rename.** Fsyncing only the temp file leaves the new name recoverable-but-absent after a power loss; a successful directory-entry fsync makes the rename durable. Its failure is ignored, so this step is best-effort. Windows has no equivalent concept, hence unix-only.

**Why the mode is set before the bytes.** Under the bare umask the transcript-bearing blob lands `0644` in a `0755` directory any other local account can read, and tightening after the write would leave a window in which it was readable. Continuing after a permission failure would contradict the owner-only guarantee; aborting before writing preserves the previous snapshot and leaves at most an empty temp file.

Expand Down
3 changes: 2 additions & 1 deletion lib/src/host/remote/burrow-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* The interface is async because the hosts that implement it are: files the
* sidecar owns here, `VsCodeBurrowStateStore` there (enrollment in
* `SecretStorage`, ACL in `globalState` — `docs/specs/vscode.md`). {@link FileBurrowStateStore}
* is the sidecar's: two files, 0600, under a directory the app passes in.
* is the sidecar's: private JSON state under a directory the app passes in
* only after establishing owner-only access (POSIX modes or a Windows DACL).
*/

import { readFile, rm } from 'node:fs/promises';
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"docs/specs/security-supply-chain.md": 1250,
"docs/specs/security.md": 2150,
"docs/specs/shortcuts.md": 1100,
"docs/specs/standalone.md": 11950,
"docs/specs/standalone.md": 12050,
"docs/specs/terminal-context.md": 1100,
"docs/specs/terminal-escapes.md": 4050,
"docs/specs/terminal-state.md": 2400,
Expand Down
Loading
Loading