Skip to content

release: 0.9.0 — audited (rounds 1-5b), #[non_exhaustive] break, Linux host-crash root-cause fix - #72

Merged
runyourempire merged 185 commits into
mainfrom
release/0.9-prep
Sep 30, 2026
Merged

runyourempire merged 185 commits into
mainfrom
release/0.9-prep

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

Do not merge until the external adversarial audit is complete. This PR carries 0.9.0 release prep: the round-2 pre-audit red-team fixes plus the one-time #[non_exhaustive] semver break. See CHANGELOG [0.9.0] and MIGRATION.md.

Opened now so the full CI matrix (3 OSes, E2E, live-app proof, gauntlet, semver checks) runs against the release candidate. The semver-checks job reporting major changes is intended.

🤖 Generated with Claude Code

https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw

audit and others added 30 commits September 27, 2026 13:04
…hrough the bridge

- on_main: a caller already on the Tauri main thread (a sync command, a menu
  handler, or a nested bridge call) now runs inline instead of spinning on
  MAIN_DISPATCH_LOCK — which could deadlock against a background holder whose
  closure is queued behind it, freezing the UI for the full 10s timeout. The
  main thread is recorded in plugin setup (which Tauri runs there).
- tools.rs: victauri_eval_js / victauri_dom_snapshot called `webview.eval`
  directly from a tokio worker; they now go through WebviewBridge::eval_webview
  (main thread + serialized, like every other round trip).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…nistic default window

Default-implemented trait additions (non-breaking for implementors):
- try_list_window_labels distinguishes "could not ask the UI thread" from
  "no windows" — an empty list from a wedged UI is not evidence a window closed.
- eval_webview_resolved returns the label the script was actually delivered to.
find_window's fallbacks no longer depend on HashMap iteration order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…window

Red-team (pre-audit) findings on the eval path:
- Pending eval ids lived on a page-visible global (window.__VIC_EVAL__): page
  script could enumerate them and forge or suppress results. The watchdog and
  settle state now live in the bridge's closure (frozen, non-configurable API
  that never reveals an id); serialization uses JSON.stringify captured at
  init, before any page script.
- A main-thread listing that timed out returned [] and was read as "window
  closed ... it most likely ran" — a busy UI now just keeps waiting; the check
  runs in its own task so it can't stall the wait past its deadline.
- The watched window is the one the script was actually delivered to
  (eval_webview_resolved), so an unlabeled eval whose default window closes is
  detected (it was resolved lazily 1s later, to a different live window).
- A trailing `// comment` in user code commented out the wrapper and turned
  the eval into a parse error; the code is now followed by a newline.
- Page-originated evals (victauri_eval_js from page JS) get their own 10-slot
  budget so they cannot starve agent evals of the shared pending map.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…t_pub_crate)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…g identity pinning, docs honesty

CI / release
- release-vscode: split into a secret-free build job (persist-credentials: false,
  npm ci --ignore-scripts, uploads the .vsix) and an environment-gated publish job
  that only runs when actually publishing; each token scoped to its one step; a real
  manual publish must be dispatched on a vscode-v* tag (dry runs work from any branch).
- surface-audit: checks all six crates vs the workspace version; an unknown/failed
  registry query now fails the job with its own message instead of passing silently.
- e2e.yml: kept (ci.yml does not run the victauri-test client suites) and fixed:
  explicit `toolchain: stable` for the SHA-pinned action, prebuilt binary, a
  `curl -sf` health retry loop instead of `sleep 10`, stderr capture, cleanup.
- release.yml: previous tag via `git describe --match 'v[0-9]*' TAG^` (backport-safe).
- docs.yml: pages/id-token permissions only on the deploy job.
- deny.toml: unknown-registry / unknown-git = deny (cargo deny check sources: ok).
- bump-version.sh: portable (no grep -P, sed -i.bak + rm); bump-version.ps1: root is
  exactly the parent of scripts/.
- LICENSE copied into every published crate (cargo package --list includes it).

victauri init
- generated workflow: `permissions: contents: read`, persist-credentials: false,
  SHA-pinning guidance for the tag-pinned victauri-test action.
- .mcp.json built with serde_json (hostile identifiers are escaped, not spliced).
- never writes through a symlink: new files use create_new after a symlink check;
  the CLAUDE.md append refuses symlinks / non-regular files. Tests added.

victauri-watchdog
- identity pinning: once resolved, only discovery entries with that identity are
  followed; a dead watched pid is a failure even if another app answers the port;
  with --app/VICTAURI_APP and no match it reports the app DOWN instead of polling
  the default port.
- discovery trust: base + entry dirs must be owned by the current euid (Unix, no
  unsafe) and not group/other-writable; Windows rejects symlinks/junctions. Tests.

Docs: README/tools-reference action names regenerated from the real enums
(+ route/trace/animation), overclaims corrected (latency, "identical on all
platforms", dev-dependency advice, WebKitGTK CDP), tools-reference semantics for
explain/db_health/replay/trace/fault/route, stale CLAUDE.md present-tense sections.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…ck "pending"

Red-team (pre-audit) findings on the injected bridge:
- Log getters returned the LIVE internal arrays: page script could push a
  forged "successful" IPC entry (which recording.replay would then invoke),
  splice out its own traffic, or plant values that broke every later read.
  They now return per-entry copies; ipcCommandPath tolerates non-strings.
- The compact DOM snapshot wrote page text raw, so a newline in rendered text
  (an RSS title, a chat message) forged `[eN] button "…"` lines and could steer
  an agent's click. Page-derived strings are now JSON-encoded.
- "Victauri-internal" traffic was detected by substring anywhere in the URL, so
  any request with `plugin%3Avictauri%7C` in its query escaped route rules and
  network logging; it's now decided from the parsed IPC command path.
- IPC events are keyed by completion time (seq_ts) and, in watermark mode, a
  pending call is emitted once when it completes — a command slower than the
  drain gap used to stay "pending" forever (and replay then skipped it).
  The Rust drain advances by seq_ts and clamps the watermark to now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…internals pulled back

One deliberate semver break so future field additions are non-breaking.
No runtime behavior change except the victauri-test rediscovery fix below.

victauri-core
- #[non_exhaustive] (output-only): DomElement, ElementBounds, DomSnapshot,
  ScoredCommand, RefHandle, MemoryDelta, VerificationResult, Divergence,
  GhostCommandReport, GhostCommand, IpcIntegrityReport, StaleCall, ErrorCall,
  AssertionResult, StateCheckpoint; enums InteractionKind, CodegenStyle.
- #[non_exhaustive] + constructors: IpcCall::new, WindowState::new + with_*
  setters, SemanticAssertion::new, RecordedSession::new, RecordedEvent::new,
  AppEvent struct variants (+ AppEvent::{state_change, dom_mutation,
  dom_interaction, window_event, console}).
- CommandInfo/CommandArg #[non_exhaustive]; new builders (with_plugin,
  with_args, with_arg, with_return_type, with_async, with_examples,
  with_example; CommandArg::new/with_schema). #[inspectable] now expands to
  the builder chain instead of struct literals.
- acquire_lock/read/write and the security/middleware modules #[doc(hidden)]
  (still used by victauri-plugin and victauri-cli).

victauri-plugin
- #[non_exhaustive]: VictauriState (+ #[doc(hidden)] for_tests()),
  PrivacyConfig, PrivacyProfile, CommandTimingStats, FaultType, FaultConfig
  (+ new), JsonShape, ContractBaseline (+ new), ContractDrift, TypeChange,
  StartupPhase, CapturedTauriEvent (+ new), TrackedTaskInfo,
  ChildProcessInfo, TraceFrame, DbCandidate.
- filmstrip -> pub(crate); js_bridge, screencast, database, PendingCallbacks,
  privacy::{observe,test,strict}_privacy_config -> #[doc(hidden)];
  mcp::parse_bridge_event_from -> pub(super).
- Auth-token startup log: char-safe 4-char prefix + length (no byte slicing,
  no 48-bit token leak). Privacy matrix comment corrected (Test/Observe are
  deny-by-default).

victauri-test
- #[non_exhaustive]: PluginInfo, PluginToolInfo (now re-exported),
  MemoryStats, SmokeCheckResult, SmokeReport, CoverageReport, CommandCalls,
  LocatorMatch, Bounds, VisualDiff, ThresholdPreset. CheckResult::new and
  VerifyReport::new added (types kept exhaustive: victauri-cli builds them).
- Rediscovery pins app identity: discover() records the app identifier
  (/info app_identifier, else discovery metadata); rediscovery only accepts
  a live server with that identifier, never falls back to port 7373, and now
  also runs once on a connection error for discovered clients. Unit tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed db_health

- query_db: an SQLite AUTHORIZER is now the enforcement point (default-deny:
  reads, functions, recursive CTEs, allowlisted PRAGMAs; a PRAGMA argument only
  for read-with-argument pragmas). The string checks missed table-valued
  pragma functions (`SELECT * FROM pragma_optimize` ran) and the parenthesized
  write form (`PRAGMA user_version(5)`); both are now refused before running.
- Both paths treat the DB file as untrusted: trusted_schema=OFF + defensive.
- db_health: the interrupt watchdog parks/unparks instead of sleeping 25ms
  steps that the drop joined (~25ms per table, capping a 5s budget at ~200
  tables); the metadata + listing phase is budgeted too; virtual tables are
  listed but never counted (counting runs module code); a failed count sets
  row_counts_complete=false with a per-table count_error; the report says the
  integrity check is quick_check (integrity_check_kind).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…g by id

- The background drain and `recording flush` kept separate watermarks and
  re-recorded each other's events. Both now go through one routine with a
  shared per-window watermark + per-window lock (VictauriState.drain_watermarks);
  flush with no label drains every window. Starting or importing a recording
  resets the floor to now, so an imported session's old start time no longer
  pulls the page's history in.
- trace: ownership is the session id it started, not a flag. Restarting a
  trace ends the previous trace's recording instead of orphaning it (with its
  drain loop); stopping a trace never stops a recording someone else started;
  the 30-min auto-stop claims its generation atomically, so it cannot stop a
  newer trace.
- victauri-test CheckResult/VerifyReport are now #[non_exhaustive]; the CLI
  uses their constructors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…d team

- Browser-originated POSTs must be JSON (415 otherwise): a page on any localhost
  origin could fire CORS-simple, no-preflight tool calls at an auth_disabled()
  server. Non-browser clients (no Origin) are unaffected.
- One GLOBAL concurrency cap: axum instantiated the per-route layer per route,
  giving /mcp and /api/tools 64 slots each.
- victauri bridge (Unix): liveness counts only our own user's processes
  (`ps -U <euid>`); `ps -A` let another user's recycled PID keep a dead app's
  discovery entry "alive" for a port squatter.
- query_db absolute paths: containment is decided lexically before touching the
  path, so errors no longer reveal whether an arbitrary file exists.
- eval auto-return: postfix ++/--, `a\n++b`, regex literals (incl. quotes
  inside them), keyword operators at line end, U+2028/2029, and a `;` followed
  only by a comment are now handled per JavaScript's ASI rules.
- Tool annotations now match what the tools can do: recording (replay),
  introspect (contract_record/check invoke commands; *_clear), logs (clear),
  window (close) are destructive; verify_state/wait_for/assert_semantic run
  caller JS; inspect/animation mutate the page. Descriptions for trace, fault,
  explain, db_health and logs corrected.
- tools_fallback.json (shown while the app is down) regenerated; a test now pins
  its names AND descriptions to the live tool router.
- WebviewBridge documents its stability contract for implementors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
0.9.0 is a deliberate one-time semver break (public data types become
#[non_exhaustive] with constructors) carrying two audit rounds of fixes.
Not published: the adversarial audit runs first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
… code under --no-default-features)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…id" while the page reloads

Found by the live-4DA soak (45,905 calls, 6 reloads, 1 error): selector
validation used document.body.matches(), so a null body during a reload threw a
TypeError that was reported as "invalid CSS selector: button". Validation now
uses a detached fragment, and a missing body reports "page not ready".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
Found by the live-4DA soak: an eval in flight when its page reloaded (HMR, a
navigation) waited out the full 30s timeout and then only guessed "…or the
webview reloaded". Every bridge (re)init already sends a ready signal; the eval
callback now records the load time per window (label from Tauri — page script
cannot affect another window), and an eval whose window loads a new page after
it was injected returns "window 'X' loaded a new page while the call was in
flight … check the app's state before re-running it". A 250ms grace absorbs
the fire-and-forget ready signal of the page the eval runs in, so a late
signal is never mistaken for a reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
Found by the live-4DA dogfood run: 4DA's get_blind_spots takes up to 57s cold,
so with the fixed 30s eval timeout an agent could not call it at all.
invoke_command now accepts timeout_ms (max 300000), a timeout error points at
it, and timeouts/app-exit/closed-window/reload outcomes are no longer recorded
as command durations (they skewed p95). Timeout messages show ms under 1s.
Changelog/migration updated with the live-test findings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
CI (windows-latest) failed connection_refused_on_discovered_client_rediscovers_
same_app_only: closed_port() bound then released a port, another process on the
runner took it and answered with non-HTTP bytes (hyper Parse(Version)) instead of
refusing. The helper now keeps the socket bound but not listening for the test's
lifetime, which refuses connections deterministically on Windows and Linux.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bpin9VXT6EoVDhoV8HJWw
…capability gate

serde accepts an externally-tagged enum ({"action":{"go_to":null}}) and, on
REST, a positional array body. Both used to resolve to the bare tool name at the
gate -- which the Test profile allows for navigate -- while the handler still
parsed and ran go_to; with FullControl they bypassed per-action disabled_tools.
The gate now refuses any call it cannot map to exactly one known action.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
Tauri camelCases command arguments by default (size_kb -> sizeKb), so an agent
passing the registry's Rust names got "missing required key" -- including on
the gauntlet app's own large_payload. CommandArg gains an optional `key`
(+ with_key / invoke_key); the macro derives it with heck exactly as
tauri-macros does, honours rename_all / rename (as #[inspectable] options, or
read from a #[tauri::command(...)] it can see), unraws r#idents, and detects
injected framework args by the type path's last segment (State<'_, a::B> was
reported as a user argument; Request/CommandScope/GlobalScope were missing).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
Recorded page text reached generated Rust with bidi/control characters intact,
which rustc rejects (text_direction_codepoint_in_{comment,literal} are
deny-by-default); they are now \u{...}-escaped. Recorded selectors are parsed by
shape from the start, with CSS string/ident escapes decoded, instead of by
substring search: a test id containing ':has-text("' no longer masquerades as
a text selector, '#a.b' / '#a:hover' are no longer treated as the id 'a.b', and
CSS-escaped ids resolve to the real id. Scrolling to text uses the new
scroll_to_by_text rather than passing the text as a CSS selector.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…s error

check/uncheck assigned el.checked and dispatched synthetic events, which React's
value tracker swallows: measured on React 18, onChange fired 0 times and the
component reverted. They now click() when the state differs and verify the
result (erroring on a disabled box or a checked radio). A ref that went stale
is now ElementNotFound instead of reading as empty text / unchecked / no
attribute. get_attribute encodes the name as a JSON string. Visual baselines
decode 16-bit, indexed and gray+alpha PNGs (normalize_to_color8) instead of
misreading or refusing them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…h, LIKE patterns and lock waits

Audit F1/F2(db side)/F7.
- The 5 MB result cap was checked only after a whole row had been
  materialized, base64'd and re-serialized; with 1 MB cells and SQLite's
  2000-column default one row could reach gigabytes. Each cell's encoded
  size is now computed from the raw value (a blob's base64 length is
  arithmetic) and charged before conversion; the re-serialization is gone
  and result_bytes stays exact.
- SQLITE_LIMIT_COLUMN = 256 for the query's result set. The limit also
  bounds table definitions, so the schema is loaded first under the
  default - otherwise one wide table made the whole database unqueryable.
- Duplicate result names (SELECT 1 AS a, 2 AS a) silently dropped a value
  from the row object; repeated names now get a `:N` suffix and `columns`
  lists exactly the keys each row carries.
- SQLITE_LIMIT_LIKE_PATTERN_LENGTH = 1000 on the query and db_health
  connections: LIKE/GLOB never check for an interrupt.
- busy_timeout 5s -> 1s: the lock wait is added to the CPU deadline while
  the query holds a read transaction that stalls WAL checkpoints.

Also backticks a doc identifier clippy's doc_markdown rejects on the base.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…proof pending slots

The eval lifecycle had several ways to tell an agent the wrong thing about code it sent:

- Parse watchdog race (C1/V-5): a 750ms timer armed by one main-thread round trip raced the
  user code delivered by a second. A busy main thread delaying the code past 750ms reported
  "syntax/parse error" for code that then RAN (an invoke_command retry ran twice). The code is
  now delivered first and a check script after it (webview evals run in order): "not begun"
  means it did not parse. No timer. The bridge keeps tombstones of settled evals, so every
  outcome is delivered at most once and code reported as never begun cannot run.
- Reload detection (C4, V-13): the 250ms grace hid real reloads that completed within it and
  misread a same-page ready signal delayed past it as a reload. Each page load now has a nonce
  (sent with the ready signal, reported by the liveness probe); a reload is a ready signal from
  a different page, confirmed by asking the page for its current nonce, so page script can no
  longer abort agent evals by forging the ready signal. bfcache restores re-announce the page.
- Pending-slot leak (C2/N2): entries were removed only on paths inside the eval future, so a
  REST client that disconnected leaked its slot; 100 leaks wedged every eval tool. Every
  reservation (agent eval, probe, page-originated eval, recording drain) is now an RAII guard.
- Typed eval outcome (C7): invoke_command timings came from an error-text prefix list and
  recorded calls that never reached the command (saturated map, dead bridge, failed injection,
  parse error). Failures are now typed NotSent / Aborted / Page; only calls that ran are timed.
- App exit (C15d): subscribe() marks the current shutdown value seen, so an eval started after
  Exit waited its full timeout. It is now checked up front (and Exit uses send_replace so the
  value flips even with no receiver left).
- Unserializable results (V-12): circular/BigInt results read "JavaScript error" and functions
  came back as a raw envelope; both are now "the code ran, but its result could not be
  serialized to JSON (<reason>)".
- Page-eval budget (V-15): per window (10) under a page-wide cap (25), so one window cannot
  starve another's page-originated evals.

Also fixes a pre-existing doc_markdown lint in a test comment that failed clippy --all-targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
… a mocked flag

- EventRecorder hands out a fresh generation per start/import (start_session,
  generation, record_event_if, stop_if_generation). A reader that captured the
  generation before a slow read can no longer append into a later recording,
  and an owner can no longer stop a later recording that reuses its session id.
- stop_if_session checks the id and stops under ONE lock (a stop+start in
  between made it stop someone else's recording: 20/200k in the regression test).
- import_if_idle: the is-recording check and the replace happen under one lock.
- IpcCall gains `mocked` (serde default, omitted when false) + with_mocked():
  a call a route rule answered in the page never reached the backend.

Audit C6 (core half), C15a, C15b, V-3 (type half).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
The map is keyed by the caller-chosen command name, so ever-new names grew it
forever. Once full, new names are not tracked; tracked commands keep
accumulating.

Audit C15e.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…nput only reaches the app

- on_main (C15c): the `abandoned` flag was checked only when the closure started, so work that
  began at the deadline still ran after the caller reported a timeout, and the dispatch lock was
  released while the closure was still queued. The closure and a timing-out caller now race
  through one compare-and-swap (Queued -> Running vs Queued -> Abandoned): a timeout error means
  the work never ran and never will; work that already started is waited for (bounded) and its
  real outcome returned, with the dispatch lock held throughout. The logic lives in a small
  `dispatch_and_wait` seam so it is unit-tested without a Tauri runtime.
- Windows trusted input (C3): the SetForegroundWindow result was ignored, then SendInput sent
  keystrokes/clicks to whatever had focus — Windows' foreground lock refuses focus while the
  user types elsewhere, so an agent's Enter could land in the developer's terminal. Input is now
  sent only after verifying GetAncestor(GetForegroundWindow(), GA_ROOT) is the target window,
  and a click only if WindowFromPoint at the target point resolves inside it; otherwise a clear
  error. `input press_key` with `trusted` and a `ref_id` now stops when the element cannot be
  focused instead of sending the key anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…sy-UI or path oracles

Audit F2 (tool side), F3-F6, C8, C10-C12.
- F2: query_db / db_health run through bounded::run_blocking_bounded: a
  deadline (op budget + 5s slack) the caller never waits past, and a
  2-slot semaphore whose permit is held by the blocking closure itself, so
  a timed-out call keeps its slot until its thread really ends. Residual:
  a single SQLite string op can still overrun its own deadline by seconds
  on the blocking thread; the caller no longer waits for it and at most 2
  such threads exist.
- F3: one ordered, de-duplicated (keep-first) root list for both tools; a
  HashSet made relative-path resolution nondeterministic and db_health
  skipped the log dir.
- F4: list_app_dir walks on the blocking pool, canonicalizes the base
  once, and stops (truncated: true) at 10k returned / 100k examined
  entries or 5s.
- F5: a read truncated mid-character drops the partial character instead
  of returning the whole file as base64.
- F6: `..` in an absolute db path is refused; missing vs escaping-through-
  a-symlink answer identically in query_db, read_app_file and list_app_dir.
- C8: look-back windows saturate to "all history" (Utc::now() - TimeDelta
  panicked; u64 -> i64 wrapped into a future baseline); REST execute_tool
  and MCP call_tool dispatch behind a catch-unwind boundary that turns a
  handler panic into an error result.
- C10: wait_for(event) never sleeps past its deadline; poll_ms is clamped
  to [20, timeout_ms] before it reaches JS.
- C11: WebviewBridge::try_get_window_states; window get_state/list/
  introspectability, capture targeting, the windows resource, introspect
  capabilities and recording flush report "UI thread busy" instead of
  empty lists / "window not found".
- C12: app_state probes run on the blocking pool with a 10s timeout; a
  panicking probe is a tool error.

Updates db_path_outside_roots_gives_no_existence_oracle for the explicit
`..` refusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…true replay, trace lifecycle

Drain (C5/V-10, C13, C6):
- The JS bridge sequences each log entry the first time a drain sees it
  complete (`drainEvents(afterSeq, instance, floorMs)` -> {instance, seq,
  events}); the Rust drain position is (page instance, seq), not a wall-clock
  watermark clamped to the Rust clock. A future-dated event was re-read on
  every drain (1 event -> 5 records in 5 drains) and same-ms events pushed
  after a read were lost; now each is read exactly once. A reload restarts the
  sequence under a new instance id. Only a window's first read in a recording
  skips pre-recording history by timestamp.
- Plain network events are keyed by completion and not emitted while pending
  in watermark mode (same fix IPC got in R2-13).
- The drain pins the recorder generation before its read and records with
  record_event_if; positions are per recording epoch (= generation), a read
  that straddles a reset cannot plant an old position, and a drain in the gap
  between `start` and the epoch reset does not read. Start/import take the
  floor before the recording becomes active; import is check-and-replace
  atomic (import_if_idle).

Replay / mocked calls (V-3):
- ipc/network stream events carry `mocked` for route-fulfilled/blocked
  requests; the drain records it on IpcCall and replay skips mocked calls.
- Replay runs each call in the window that recorded it (never a default
  window; a gone window fails the call); `webview_label` now filters.
- The drain reply is serialized by the bridge's pristine JSON.stringify via
  `_evalSettle`, not the page's.

Page JSON (V-4):
- mcp::page_json::{sanitize_lone_surrogates, parse_page_json}: escape-aware
  rewrite of unpaired \uD800-\uDFFF escapes to �. Used by the drain, the
  eval envelope (so every eval_with_return consumer: introspect
  command_timings/coverage/command_catalog, explain) and those sites directly.
  The drain's position comes from the bridge counter, so an unparseable entry
  no longer stalls a window.

Trace (C14):
- active/generation/owned recording live under one mutex; ownership is by
  trace generation + recorder generation (no ABA via session ids); an old
  task's auto-stop can no longer take a newer trace's recording.
- interval clamped to 50..=60000 ms so the 30-min auto-stop keeps firing.
- CaptureTaskGuard ends the trace and its recording however the capture task
  exits, including a panic.

Also: backticks for a pre-existing clippy doc_markdown hit in mod.rs tests;
tools_fallback.json regenerated for the recording description.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
…ge e2e isolated

Windows liveness spawned tasklist and substring-matched its output: PID 12
'matched' 123, other users' processes counted (the cross-user PID-reuse fix
R2-7 only reached Unix), victauri-test resolved tasklist via PATH, and the
bridge ran a full tasklist snapshot every 1.5s even with no discovery entries.
Measured under load: 0.5s per call, up to 40s when filtered by user -- which
stalled the bridge's own replies and made bridge_e2e fail 3 of 7 runs.
Liveness is now OpenProcess + exit code + token-SID == ours (victauri-test
process module, copied into the watchdog; unsafe confined to that module), and
scans skip enumeration when nothing was discovered. The e2e tests write their
fake discovery entries into a private temp root instead of the real one, where
a developer's own victauri bridge connected to the mock mid-test (observed).
bridge_e2e: 12/12 green, ~2s each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We4pMEPqPFqE4WVQy4GWGz
runyourempire and others added 27 commits September 30, 2026 02:27
…yed (R5B-TERM3)

`TestError::ToolError` and `TestError::Mcp` messages often carry page-derived
text (a JS exception message) and were printed raw by their Display impls: an
ESC sequence could drive the terminal, and a line of its own starting with
`::error ...` (or legacy `##[...]`) became a forged GitHub Actions annotation in
a CI log. New `terminal::untrusted_multi_line` keeps legitimate line breaks,
escapes control/bidi characters (as `multi_line` does), and escapes the first
character of any line that would read as a workflow command; both Display impls
use it. The stored message itself is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…t start (R5B-SMOKE1)

`assert_recording_lifecycle` called `stop_recording()` first "to clean up",
which ended -- and discarded -- any recording already in progress, e.g. a
concurrent `victauri record` session. It now starts first (race-free: the app
refuses a second session); if a recording is already active it leaves it
running and does not run. The smoke suite reports that check as skipped with
the reason (new `SmokeCheckResult::skipped` field, `[SKIP]` in the summary; a
skip counts as passed). Once the check owns its recording it stops it on every
path, so a failed eval no longer leaves it running.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…r PATH (R5B-BR7)

On Unix the bridge had its own liveness: `ps -U` / `kill -0` resolved from
/bin or /usr/bin, else a BARE name looked up on PATH. On NixOS/Guix/minimal
images that meant a PATH lookup (a hijacked PATH decides which PIDs look alive,
i.e. where the Bearer token goes), or no liveness at all and no app found.
Per-PID liveness now calls `victauri_test::process::is_own_live_process` on
every platform (the R4-hardened kill/sh-builtin//proc chain Windows already
used). The batched `ps -U` snapshot is kept for speed but is resolved only from
fixed locations incl. the NixOS/Guix system profiles, and is skipped (falling
back to the hardened per-PID check) when absent -- never a bare-name lookup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…B-FETCH0)

With no arguments the interceptor converted the missing input to the string
"undefined" and fetched `./undefined` (logged as a real request) instead of
letting native fetch reject with its TypeError. It now delegates a zero-arg
call to the original fetch untouched, and forwards `fetch(x)` as one argument
rather than growing an explicit `undefined` init. The jsdom harness's fetch
stub now rejects a zero-argument call as native fetch does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
The stdio loop awaited each forward before reading the next line, so one long
`tools/call` (the default HTTP timeout is now 330 s) delayed `ping`, every
parallel tool call, and the `notifications/cancelled` meant to stop it.

Now:
- local methods (initialize, the client's notifications/initialized, ping,
  batches, invalid messages) are answered inline;
- every forwarded request runs as its own task, bounded at 32 in flight (past
  that a request is refused at once with an error, never queued unbounded);
- notifications (incl. cancelled) and client responses are forwarded promptly
  and in order by one dedicated task (bounded queue);
- each forward holds its resolved backend in a local, and the lazy backend
  handshake is single-flight (a tokio mutex + re-check), so concurrent requests
  never race several handshakes; a stale reply clears the session only if it
  is still the one that request used (compare-and-clear).
Kept: one complete JSON line per locked stdout write, the no-replay rule for a
possibly-delivered tools/call, list_changed only after notifications/initialized.
On stdin EOF in-flight requests get a bounded grace period to answer.

E2E: two 4 s `slow` calls + ping + notifications/cancelled -- the ping is
answered first and at once, the cancel reaches the backend while the call runs,
and both calls finish in ~one call's time. (Before: the ping's reply came after
the slow call.) The client-response test now polls for the async forward.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
… error (R5B-ISERR1)

interact, input, inspect and `route add` returned the page's
`{ok:false, error, hint}` (or `{error}`) as a SUCCESS result with isError
unset, so a refused click (covered, disabled, detached, ref not found) read
as done unless the caller parsed the body — VictauriClient/Locator
returned Ok, and the VS Code "Click element" command reported success.
They now return isError with the page's message and its recovery hint in
the standard `[hint: RETRY_LATER|CHECK_INPUT]` form (only an UPPER_SNAKE
hint word is carried over — it is page-controlled). Array results with
per-item errors (get_bounding_boxes) and results that merely contain an
`error` field below the top level stay successes.

Callers: victauri-test client/Locator need no change (they already map
isError to TestError::ToolError; a refusal is now Err instead of a silent
Ok). The demo-app adversarial stale-ref test now requires the tool error.
Tool descriptions + docs/tools-reference.md document the behavior; CLI
fallback manifest regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
`--app` bound a backend from discovery metadata + PID liveness only. After a
crash the app's stale entry can carry a PID that another of our processes
reused, and if a DIFFERENT Victauri app (auth disabled) now holds the port the
bridge silently drove the wrong app (the residual bridge.rs documented).

With an app selector, the resolved backend's own `/info` (app_identifier /
app_product_name, compared exactly and ASCII case-insensitive like matches_app)
must now match before anything is forwarded; a mismatch or a server that does
not confirm its identity is treated as "not found" with a message naming what it
reported, and nothing is sent to it. The confirmation is cached per resolved
(pid, port, token), so the steady state costs no extra request per call, and is
re-checked whenever the resolution changes (an entry without a pid -- a bare
VICTAURI_PORT -- is re-checked every time). The e2e mocks now serve /info like
the real plugin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…(R5B-PORTAPP1)

With VICTAURI_PORT set, `--app` / VICTAURI_APP was silently ignored by both the
bridge and victauri-test's discovery (so every other CLI command): the client
drove whatever app held that port. When both are set they are now checked
against each other:

- bridge: the one live discovery entry on that port must match the selector
  (metadata), and the server's own /info identity must match too (the R5B-BR6
  confirmation, cached per pid/port/token); otherwise the call is refused with
  "VICTAURI_PORT=N is app X, but the app selector ... is 'Y'" and nothing is
  forwarded.
- victauri-test: `try_resolve_connection` now scans discovery when a selector is
  set even with an explicit port + token, and refuses a disagreeing entry;
  `discover_app` re-checks the selector against the reached server's /info
  (identifier or product name) and refuses a mismatch -- which also covers a
  port with no discovery entry. Shared `identity_matches` rule (exact, ASCII
  case-insensitive).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…ns (R5B-SQLFN1)

The query_db authorizer allowed every SQL function, so a side-effecting
function the host registers process-wide (sqlite3_auto_extension) ran
despite the read-only open. The authorizer now applies a per-connection
function policy computed from that connection's pragma_function_list: a
name is allowed if every registration under it is built in, registered
SQLITE_DETERMINISTIC, or one of SQLite's own extension functions (FTS3/4
snippet/offsets/matchinfo/match, FTS5 bm25/highlight/snippet/..., R-Tree,
Geopoly). A non-deterministic app function - including one overriding a
built-in name - is refused with an error naming it. load_extension and
FTS3's optimize are never allowed. If pragma_function_list is unavailable
the policy fails closed to a hard-coded list of SQLite built-ins; a test
pins that list against the linked SQLite so a newer SQLite cannot drift.

Also fixes a pre-existing break found by the new tests: R-Tree tables were
unreadable through query_db (the R-Tree module prepares its shadow-table
INSERT/UPDATE/DELETE when it connects, which the authorizer refused). Write
preparation is now allowed on the R-Tree/Geopoly shadow tables only; the
READ_ONLY open still stops any write from executing.

The authorizer is now installed after the schema load, so a locked database
fails once (within the 1 s busy timeout) instead of in every policy read.
SetupOutcome keeps the install fail-closed across rusqlite 0.32..0.40.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…-DATAURL1)

The network log (up to 1000 entries) kept every request URL verbatim, so an
app that fetches large data: URLs pinned 1000 x their size: a jsdom PoC of
1000 fetches of a 1 MB data: URL retained 1,048,596,000 URL chars (~1 GB+);
now 2,065,000. A logged URL longer than 2048 chars is cut (surrogate-safe,
truncText) with a `…[+N chars]` marker; IPC URLs are never cut, and route
matching still sees the full URL. Route matching also no longer resolves
opaque (data:/blob:/javascript:/about:) or >8 KB URLs through the URL parser,
which only cost time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…e code (R5B-QC1)

On SQLite >= 3.44 a whole-database PRAGMA quick_check connects every
virtual table whose module is registered and runs the module's integrity
routine - module code, including a module the host registered process-wide
(an auto-extension, e.g. a vector-search extension). Verified: with such a
module registered and a virtual table using it in the file, db_health ran
its constructor ("vtable constructor failed").

db_health now lists the connection's modules (pragma_module_list). If the
file has (or may have) virtual tables and a non-built-in module is
registered - or the list is unavailable - it runs PRAGMA quick_check(<t>)
per ordinary table instead, which connects no virtual table, and reports
integrity_check_kind "quick_check (per table)" with the reason in the new
integrity_check_note field (virtual tables and the free list are then not
checked). Otherwise the whole database is still checked, SQLite's own
FTS/R-Tree tables included; a vtab whose module is not loaded is not
connected. Budgets are unchanged (the per-table pass shares the check
budget). New integrity_check_note is null in the whole-database case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…o (R5B-EVALWIN1)

victauri_eval_callback delivered a result for any pending eval id to
whichever webview sent it. Every pending-eval reservation now records its
target window (PendingSlot::bind_window: agent evals, liveness probes,
recording drains, page evals), and the callback drops (with a debug log) a
result whose calling webview is not that window. An eval sent to the
default window (label None) is checked against the window the default
resolves to when the result arrives, and is narrowed to the concrete label
as soon as eval_webview_resolved reports it. A result for an unbound or
unknown id is refused. The binding lives exactly as long as the slot.

The bridge-ready signal is unchanged (it carries no eval id and records the
load against the calling webview's own label).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
… JSON (R5B-REDACT1)

Redaction is best effort, but two common leak paths were not covered:

- Secrets in URL query strings/fragments (IPC/network logs carry full,
  often tokenized URLs). The value of access_token, refresh_token,
  id_token, auth_token, token, api_key/apikey, key, client_secret, secret,
  password, pwd, auth, sig/signature and the AWS/GCS signing params after
  ?, & or # is now replaced (whole parameter names only: ?keyboard= and
  &tokens_used= are untouched).
- Key-based redaction ran only when the WHOLE output parsed as JSON. It now
  also follows JSON carried inside JSON string values (captured bodies are
  JSON-encoded strings; up to 4 levels), and redacts "key": value pairs in
  plain text and in truncated JSON fragments (incl. a value cut off at the
  end of the text).

docs/src/security.md lists the new coverage and the remaining limits: text
probes (find_elements text search even under Observe, wait_for text under
Test, assert_semantic/eval_js under FullControl) can still confirm redacted
content piecewise; secrets under innocuous keys, in non-JSON formats, or in
URL paths are not recognised.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…s deterministic (R5B-FLAKY)

Root causes, measured under 2x CPU oversubscription on 16 cores:

- bridge `a_job_outliving_its_caller…` (and two siblings) posted the job to a
  freshly spawned stand-in "main thread" and gave the caller 50 ms. Under
  load the thread is scheduled after the deadline, the caller correctly
  abandons a job that never started, and the "job outlived its caller"
  precondition fails: 30/30 failures in a 30-run loop. A new `post_started`
  stand-in returns only once the job has signalled that it started, the
  first job now runs until explicitly released, and it is released only
  once the second caller holds the dispatch lock (provably waiting on it):
  no sleep decides any outcome. 0/30 after.
- `app_state_probes_are_bounded_and_panic_isolated` and the two
  `read_app_file` tests ran EVERY probe/read under a 1 s `cfg(test)`
  deadline. A panicking probe needs a new blocking-pool thread (the stuck
  probe holds the first) plus the panic hook and unwind: ~0.4 s at 2x
  oversubscription (0.7 ms idle), more in a full parallel suite, so it was
  reported as "did not finish within 1000 ms" (reads: 2/30 failures). The
  deadlines are now per-handler fields with the unchanged production
  values (10 s / 15 s — no production deadline widened); only the tests
  that exercise the deadline itself shorten them. 0/30 after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…ORIS)

After R5-NET1 a silent connection no longer holds a request slot, but a
local process could still fill all 256 slots with started-but-stalled
request heads (one byte, then nothing), each held until the 30 s header
deadline. Every client of this server sends a complete head in one write,
so ServeLimits::DEFAULT now uses 10 s: still generous for a machine stalled
by a debugger or load, and keeping every slot busy now takes three times
the reconnect rate. The deadline is effectively measured from the first
byte (hyper starts the timer once serve_hardened has admitted the
connection on its first byte); it is also the idle keep-alive timeout,
which HTTP clients handle by reconnecting. Slow-loris from a loopback
process that keeps reconnecting stays bounded, not prevented.

Existing tests now use the default deadline instead of a hard-coded 30 s;
a stale "64 request slots" comment is corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…R5B-PRISTINE1)

The fetch interceptor parsed captured IPC request/response bodies (and a route
fulfill body) with the page's JSON.parse / JSON.stringify, so page script
replacing them forged the args/results that logs ipc, replay and the command
catalog report. They now use PRISTINE_PARSE / PRISTINE_STRINGIFY.

The in-app victauri_eval_js command (and victauri_dom_snapshot, same pattern)
built its result and callback args with the page's JSON and a plain object: a
throwing Object.prototype.toJSON made every page-originated eval hang until
its timeout, and a replaced JSON.stringify forged the result. The script is
now js_bridge::page_eval_script: the body comes from a new bridge method
_pageEvalBody (init-time stringify, blind to universal-prototype toJSON,
never throws: an unserializable value is reported as {"__error": ...}) and the
callback args are a null-prototype object, mirroring R5-JS2 for agent evals.
Result format is unchanged except that an `undefined` result now arrives as
`null` instead of never arriving. Also fixes a clippy doc_markdown nit in the
R5B-XHR1 test comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…ns (R5B-WINDISC1)

On Windows every discovery reader trusted any directory under
`%TEMP%\victauri`. When TEMP is shared (an app launched from MSYS2 uses
`C:\msys64\tmp`; a redirected `C:\Windows\Temp`), another user could plant
`victauri\<live pid>\{port,token,metadata.json}` pointing at a port they
control, and clients sent it agent calls and accepted forged results.

Readers now trust a root or `<pid>` directory on Windows only if it is a real
directory (no symlink/junction) whose owner SID is the token user, the token's
default owner, or BUILTIN\Administrators when this token is a member (enabled
or deny-only) -- the plugin writer's `acceptable_owner_sids` rule incl. 659e353:
- victauri-test: `process::dir_owned_by_current_user` (GetNamedSecurityInfoW +
  token SIDs, next to the existing Win32 liveness code); discovery.rs uses it.
- CLI bridge: calls victauri-test's check (the crate forbids unsafe); the
  documented "Windows ACL residual" is closed.
- watchdog: the same check in its process.rs copy.
- VS Code: TypeScript cannot call Win32, so a directory outside the user
  profile is checked with one batched `Get-Acl` PowerShell call (absolute
  powershell.exe path, paths passed via env, 10 s timeout, fails closed),
  cached per directory identity (inode + birth time). A directory inside the
  profile (the default %LOCALAPPDATA%\Temp) needs no check: Windows grants no
  other non-admin user access to a profile, so it costs no process spawn.

Tests (Windows; SYSTEM stands in for "another user" via `icacls /setowner`,
skipped when not elevated): planted entry and planted root refused in each
reader; our own entries still found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…ken over (R5B-LINDISC1)

On Linux without a private XDG_RUNTIME_DIR the discovery root is the
predictable /tmp/victauri-<euid>. Another local user can pre-create it; the app
refuses it (not owned) and never registers, and sticky /tmp stops the victim
deleting it -- a persistent discovery DoS.

Writer (plugin mcp/server.rs, discovery_root only): the root is now decided
once per process (OnceLock, so write and cleanup agree). When the primary root
is untrusted or cannot be created, the entry goes to the per-user home state
root -- $XDG_STATE_HOME/victauri, else $HOME/.local/state/victauri (absolute
only) -- created 0700, verified with the same private-dir check, its parent
created 0700 when missing and required to be ours and not other-writable. No
usable fallback -> the primary is kept and fails closed as before.

Readers (victauri-test discovery, CLI bridge, watchdog, VS Code) scan, in order:
$XDG_RUNTIME_DIR/victauri, <temp>/victauri-<euid>, the home state root, then
the legacy <temp>/victauri -- all with the existing ownership/permission
checks. Doc comments, the token-location hint, the watchdog --help text and
docs/src (security, getting-started, faq, configuration, architecture) describe
the roots.

Verified on WSL with a GENUINELY foreign-owned /tmp/.../victauri-1000 (chowned
to nobody): before, the plugin's port_file_roundtrip failed with EACCES; after,
the entry lands in $HOME/.local/state/victauri (0700) and the real `victauri
invoke` and `victauri bridge --app` binaries find the app there. Unit tests
cover the selection (symlinked / blocked primary), the XDG_STATE_HOME rule and
the parent guard, and each reader's root order; a Unix CLI e2e finds an app
registered only under the home root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
# Conflicts:
#	crates/victauri-plugin/tests/bridge_r5b_tests.rs
…terministic tests, docs

- Every Victauri HTTP client (CLI bridge, victauri-test, watchdog) drops pooled
  connections after 5 s, below the plugin's new 10 s idle keep-alive, so a pooled
  connection is never reused just as the server closes it (that loss would read as
  "possibly delivered" and a tool call would not be retried).
- rate_limiter_concurrent_acquire (core + plugin): the fixed 1,200 ceiling failed 19/20
  under CPU load; the bound is now 1,000 + 1 per elapsed ms, which only a refill
  double-count exceeds.
- InterruptGuard counts watchdog wake-ups in test builds; interrupt_guard_parks_instead_of_polling
  pins the park-until-deadline behaviour deterministically (a 25 ms stepping watchdog wakes
  11x in 300 ms and fails it). db_health's 400-table test sets up in one transaction
  (~75 s -> <2 s) and checks completeness under generous budgets; lock_wait_is_short asserts
  the configured busy_timeout; the LIKE/GLOB test's wall-clock bound never discriminated.
- smoke: the skippable recording check goes through a skippable_check! macro; the doc-count
  lint counts both forms (it read 10 checks and failed the docs' correct 11).
- Docs: CHANGELOG round-5b section + MIGRATION behavior notes; stale token paths, a false
  "token is printed to the log" claim, the 30 s header deadline, a 10 s dispatch-timeout
  comment and db_health's per-table check corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
… log message

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
… CI)

The loadend helper resolved false after a 2 s fallback. The requests go to the jsdom
origin with no server, and on the Windows runner a refused localhost connection takes
~2 s to fail (TCP retries), so the fallback fired first and the test read an entry that
was genuinely still in flight (then correctly aborted by the next open()). The fallback
is 15 s and the test now asserts every request's loadend actually fired.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
…w the host answered

On the Windows runner something answers the jsdom origin with 404 (locally nothing
listens, so the request ends as `error`). The property under test is that re-open
closes the first entry as `aborted` and the second reaches a final state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015A4eZdnsnQtTqs4DPRScaM
@runyourempire
runyourempire marked this pull request as ready for review September 30, 2026 12:50
@runyourempire runyourempire changed the title release: 0.9.0 — pre-audit hardening + #[non_exhaustive] break (DO NOT MERGE before audit) release: 0.9.0 — audited (rounds 1-5b), #[non_exhaustive] break, Linux host-crash root-cause fix Sep 30, 2026
@runyourempire
runyourempire merged commit bb17a65 into main Sep 30, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant