From c02cdeb2237f70a27f75ba8a365dea04939077bd Mon Sep 17 00:00:00 2001 From: qiqi Date: Mon, 13 Jul 2026 15:19:55 +0800 Subject: [PATCH 1/4] fix(agent): auth-cache TTL survives sleep; flush on lock/wake/idle; serialize prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from a security review of the agent's Touch ID auth caches: - Dual-clock expiry (CacheExpiry): macOS Instant reads CLOCK_UPTIME_RAW, which pauses during sleep, so a grant issued before lid-close stayed live on wake. Entries are now valid only while BOTH the monotonic and wall clocks are within the TTL (mono caps awake time and bounds NTP backwards steps; wall counts sleep). - Idle timeout judged on both clocks too (a nap-happy laptop could never accumulate 30 awake minutes), and idle now flushes both auth caches unconditionally — previously the flush was gated on SSH keys being loaded, so decrypt-only agents never idle-flushed grants. - New cache watcher (5s tick): flushes grants on screen lock and on wake-from-sleep (wall/mono divergence >= 30s), sudo-timestamp semantics. Skips the WindowServer poll while both caches are empty. - All 7 human-prompt paths (sign, decrypt@vt, auth@vt, run@vt, sign@vt) now go through authenticate_serialized: run@vt's Semaphore(1) generalized so a hostile VT_AUTH peer cannot stack N dialogs, and the blocking auth chain moved to spawn_blocking so concurrent prompts cannot pin tokio workers. The sign no-context path now reports Unavailable instead of flattening it to Rejected. - decrypt@vt: empty batch is rejected as BadRequest before any prompt ("decrypt 0 secrets" spam); after a fresh approval the whole batch is granted (strict-TTL, no sliding refresh) so entries that expired while the prompt sat on screen are refreshed. - Docs: CLAUDE.md agent auth-cache section + CLI help warning that forwarded-agent (ssh -A) requests share the local session's cache context within the TTL. Clock-injected regression tests replace the sleep-based ones (dual-clock expiry, idle_exceeded, watcher_should_clear). --- CLAUDE.md | 30 ++ src/main.rs | 4 +- src/server_macos/security.rs | 8 + src/server_macos/ssh_agent.rs | 601 ++++++++++++++++++++++++++-------- 4 files changed, 513 insertions(+), 130 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24ddac1..b60a0be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,36 @@ group/other-readable file logs a `chmod 600` warning since it holds secrets. See `config.example.toml` for the template. (The previous JSON file `~/.config/vt/cf_config.json` was removed; this TOML fallback replaces it.) +### Agent auth cache (opt-in) + +`vt ssh agent` can cache a successful Touch ID / FIDO2 / password decision so +repeat `sign` / `decrypt@vt` requests within a TTL skip the prompt. Both caches +default to `none` (every request prompts): + +| flag | default TTL | scope | +|---|---|---| +| `--ssh-auth-cache-mode` + `--ssh-auth-cache-duration` | 120s | SSH `sign`, per key fingerprint | +| `--decrypt-auth-cache-mode` + `--decrypt-auth-cache-duration` | 30s | `decrypt@vt`, per record (`SHA-256(type‖salt‖host)`); any legacy item in a batch disables caching for that batch | + +Modes: `per-session` keys on the terminal session leader (`getsid` + start +time); `per-app` keys on the nearest `.app/Contents/` ancestor (all tabs of one +terminal app share a context). Peers without a controlling TTY never cache. +`auth@vt` / `run@vt` NEVER cache. + +Expiry is tracked on **both** the monotonic and the wall clock (macOS `Instant` +freezes during sleep — either clock passing the TTL kills the entry), grants are +strict-TTL (no sliding refresh), and both caches are flushed on: agent `lock`, +screen lock, wake-from-sleep (detected via clock divergence), and idle key +clearing. All prompts are serialized through a global one-permit semaphore so a +hostile peer cannot stack dialogs. + +**Forwarded-agent hazard (deliberate tradeoff, off by default):** the cache +context is derived from the *local* peer process. Requests arriving over a +forwarded socket (`ssh -A`, incl. ControlMaster-multiplexed sessions) all +resolve to the local `ssh` process's terminal session, so within the TTL a +process on the remote host can sign / fetch cached-record DEKs silently. Keep +`none` when forwarding to hosts you don't fully trust. + ### Agent audit push (opt-in) `vt ssh agent` can push one audit record per Touch ID decision (encrypt@vt / diff --git a/src/main.rs b/src/main.rs index 198d847..dd1ce30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -314,7 +314,7 @@ pub enum SshCommands { #[arg( long = "ssh-auth-cache-mode", default_value = "none", - help = "Sign auth cache mode: none, per-session, or per-app" + help = "Sign auth cache mode: none, per-session, or per-app. WARNING: the cache context is the LOCAL terminal session/app — requests arriving over a forwarded agent socket (ssh -A) from a remote host share it, so within the TTL a hostile remote process can sign silently. Keep 'none' if you forward this agent to hosts you don't fully trust." )] auth_cache_mode: server_macos::ssh_agent::AuthCacheMode, #[arg( @@ -326,7 +326,7 @@ pub enum SshCommands { #[arg( long = "decrypt-auth-cache-mode", default_value = "none", - help = "Decrypt auth cache mode: none, per-session, or per-app. Only v2 envelope URLs are cache-eligible; legacy items always prompt." + help = "Decrypt auth cache mode: none, per-session, or per-app. Only v2 envelope URLs are cache-eligible; legacy items always prompt. Same forwarded-agent caveat as --ssh-auth-cache-mode." )] decrypt_auth_cache_mode: server_macos::ssh_agent::AuthCacheMode, #[arg( diff --git a/src/server_macos/security.rs b/src/server_macos/security.rs index 2356739..042670f 100644 --- a/src/server_macos/security.rs +++ b/src/server_macos/security.rs @@ -33,6 +33,14 @@ fn screen_state_now() -> SessionState { classify_session(cgsession::fetch_flags()) } +/// True when the GUI session can present an auth dialog right now (on +/// console, login done, screen unlocked). Used by the SSH agent's cache +/// watcher to flush standing grants when the screen locks. Uncached — the +/// watcher polls on a multi-second tick, so no TTL cache is needed. +pub fn session_interactive_now() -> bool { + matches!(screen_state_now(), SessionState::Interactive) +} + fn screen_state_cached() -> SessionState { static CACHE: OnceLock>> = OnceLock::new(); let mu = CACHE.get_or_init(|| Mutex::new(None)); diff --git a/src/server_macos/ssh_agent.rs b/src/server_macos/ssh_agent.rs index 80b074e..c79569c 100644 --- a/src/server_macos/ssh_agent.rs +++ b/src/server_macos/ssh_agent.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use anyhow::Result; use async_trait::async_trait; @@ -18,7 +18,7 @@ use ssh_key::{Algorithm, HashAlg, Signature}; use tokio::sync::{RwLock, Semaphore}; use crate::core::crypto::{derive_dek, AesGcmCrypto}; -use crate::core::session::AuthOutcome; +use crate::core::session::{AuthOutcome, UnavailableReason}; use crate::core::wire::{ outcome_to_err_strict, wrap_ok_envelope, ErrKind, WIRE_VERSION, }; @@ -28,9 +28,7 @@ use crate::core::{ }; use rand::RngCore; use zeroize::{Zeroize, Zeroizing}; -use super::security::{ - authenticate, derive_passcode_ciphers, load_mac_cipher, local_authentication, -}; +use super::security::{authenticate, derive_passcode_ciphers, load_mac_cipher}; use super::store::KeychainStore; use super::audit::{self, AgentAuditEntry, AuditPushConfig}; @@ -129,9 +127,31 @@ impl std::fmt::Display for AuthCacheMode { /// cached grant. pub type CacheContext = (u64, u64); +/// A cache entry's expiry, tracked on two clocks. +/// +/// `std::time::Instant` on macOS reads `CLOCK_UPTIME_RAW`, which does NOT +/// advance while the system is asleep — an expiry tracked only on `Instant` +/// pauses its countdown when the lid closes, so a grant issued just before +/// sleep would still be live on wake hours (or days) later. The wall clock +/// keeps counting through sleep but can be stepped backwards (NTP), which +/// would stretch a wall-only expiry. An entry is therefore valid only while +/// BOTH clocks agree: `mono` caps total awake time at the TTL, `wall` caps +/// total real time at the TTL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CacheExpiry { + mono: Instant, + wall: SystemTime, +} + +impl CacheExpiry { + fn is_valid_at(&self, now_mono: Instant, now_wall: SystemTime) -> bool { + now_mono < self.mono && now_wall < self.wall + } +} + pub struct AuthCache { /// Each entry stores when it expires (computed at grant time). - entries: HashMap<(CacheContext, String), Instant>, + entries: HashMap<(CacheContext, String), CacheExpiry>, /// Lifetime of a fresh grant. ttl: Duration, } @@ -145,36 +165,120 @@ impl AuthCache { } pub fn is_authorized(&self, context: CacheContext, fingerprint: &str) -> bool { - if let Some(expires_at) = self.entries.get(&(context, fingerprint.to_string())) { - Instant::now() < *expires_at - } else { - false - } + self.is_authorized_at(context, fingerprint, Instant::now(), SystemTime::now()) + } + + /// Clock-injectable core of [`is_authorized`], unit-testable for the + /// sleep scenario (mono frozen, wall advanced) without real sleeping. + fn is_authorized_at( + &self, + context: CacheContext, + fingerprint: &str, + now_mono: Instant, + now_wall: SystemTime, + ) -> bool { + self.entries + .get(&(context, fingerprint.to_string())) + .is_some_and(|e| e.is_valid_at(now_mono, now_wall)) } /// Strict-TTL grant: a still-valid entry's expiry is left untouched. /// Concurrent grants for the same key cannot extend the original TTL. /// Expired entries (or absent ones) are replaced with a fresh expiry. pub fn grant(&mut self, context: CacheContext, fingerprint: &str) { - let now = Instant::now(); - let new_expires = now + self.ttl; + self.grant_at(context, fingerprint, Instant::now(), SystemTime::now()); + } + + fn grant_at( + &mut self, + context: CacheContext, + fingerprint: &str, + now_mono: Instant, + now_wall: SystemTime, + ) { + let fresh = CacheExpiry { + mono: now_mono + self.ttl, + wall: now_wall + self.ttl, + }; self.entries .entry((context, fingerprint.to_string())) - .and_modify(|expires_at| { - if *expires_at <= now { - *expires_at = new_expires; + .and_modify(|e| { + if !e.is_valid_at(now_mono, now_wall) { + *e = fresh; } }) - .or_insert(new_expires); + .or_insert(fresh); } - pub fn clear(&mut self) { + /// Drop every entry; returns how many were dropped (for flush logging). + pub fn clear(&mut self) -> usize { + let n = self.entries.len(); self.entries.clear(); + n + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() } pub fn sweep_expired(&mut self) { - let now = Instant::now(); - self.entries.retain(|_, expires_at| now < *expires_at); + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); + self.entries.retain(|_, e| e.is_valid_at(now_mono, now_wall)); + } +} + +/// True when `timeout` has elapsed since the last activity on EITHER clock — +/// the same dual-clock rule as [`CacheExpiry`] (which documents why), applied +/// to the idle timeout: without the wall clock a laptop that naps often could +/// stay "active" for days and never drop its keys. +fn idle_exceeded( + last_mono: Instant, + last_wall: SystemTime, + now_mono: Instant, + now_wall: SystemTime, + timeout: Duration, +) -> bool { + let deadline = CacheExpiry { + mono: last_mono + timeout, + wall: last_wall + timeout, + }; + !deadline.is_valid_at(now_mono, now_wall) +} + +// --- Cache watcher (screen lock + sleep/wake invalidation) --- + +/// How often the cache watcher samples screen-lock state and clock skew. +const WATCHER_TICK: Duration = Duration::from_secs(5); +/// Wall clock advancing this much further than the monotonic clock between +/// two watcher ticks means the system slept (mono pauses during sleep). +/// Large enough to also absorb small NTP steps without spurious clears. +const WATCHER_SLEEP_DIVERGENCE: Duration = Duration::from_secs(30); + +/// Decide whether the auth caches must be flushed for this watcher tick. +/// +/// Two triggers, both "the human walked away" signals that must revoke +/// standing grants (sudo-timestamp semantics): +/// 1. Screen transitioned interactive → not interactive (locked, fast-user +/// -switched, or logged out). Only the *transition* clears, so a grant +/// issued after unlock isn't immediately eaten by the steady locked +/// state of some other display. +/// 2. Sleep detected: wall time advanced ≥ [`WATCHER_SLEEP_DIVERGENCE`] +/// more than monotonic time since the previous tick. `wall_delta` is +/// `None` when the wall clock stepped backwards — not a sleep signal; +/// the dual-clock TTL ([`CacheExpiry`]) still bounds those entries. +fn watcher_should_clear( + was_interactive: bool, + is_interactive: bool, + mono_delta: Duration, + wall_delta: Option, +) -> bool { + if was_interactive && !is_interactive { + return true; + } + match wall_delta { + Some(wall) => wall.saturating_sub(mono_delta) >= WATCHER_SLEEP_DIVERGENCE, + None => false, } } @@ -758,7 +862,9 @@ fn get_peer_pid(stream: &tokio::net::UnixStream) -> Option { pub struct VtSshAgentFactory { keys: Arc>>, - last_activity: Arc>, + /// Last request time on both clocks — see [`idle_exceeded`] for why the + /// monotonic clock alone can't drive the idle timeout. + last_activity: Arc>, locked: Arc>, lock_passphrase: Arc>>, idle_cleared: Arc>, @@ -773,12 +879,9 @@ pub struct VtSshAgentFactory { disable_legacy_decrypt: bool, /// run@vt allowlist. Empty = feature disabled. run_allow: Arc, - /// Global serializer for run@vt Touch ID prompts. One permit means a - /// remote attacker holding VT_AUTH and opening N parallel SSH agent - /// connections cannot queue N concurrent prompts at the user — they - /// must approve or reject each in sequence. Critically, run@vt has - /// no auth cache (same rule as auth@vt — see check_or_prompt_run). - run_prompt_sem: Arc, + /// Global serializer for ALL human auth prompts; see + /// [`VtSshSession::authenticate_serialized`] for the rationale. + prompt_sem: Arc, /// Fire-and-forget audit push config. Cloned per session like `run_allow`. /// Disabled config = audit push is a no-op. audit_push: Arc, @@ -795,7 +898,7 @@ impl VtSshAgentFactory { ) -> Self { Self { keys: Arc::new(RwLock::new(keys)), - last_activity: Arc::new(RwLock::new(Instant::now())), + last_activity: Arc::new(RwLock::new((Instant::now(), SystemTime::now()))), locked: Arc::new(RwLock::new(false)), lock_passphrase: Arc::new(RwLock::new(None)), idle_cleared: Arc::new(RwLock::new(false)), @@ -805,7 +908,7 @@ impl VtSshAgentFactory { decrypt_cache_mode: decrypt_cache.mode, disable_legacy_decrypt, run_allow: Arc::new(run_allow), - run_prompt_sem: Arc::new(Semaphore::new(1)), + prompt_sem: Arc::new(Semaphore::new(1)), audit_push, } } @@ -872,7 +975,7 @@ impl Agent for VtSshAgentFactory { sign_auth_cache: Arc::clone(&self.sign_auth_cache), decrypt_auth_cache: Arc::clone(&self.decrypt_auth_cache), run_allow: Arc::clone(&self.run_allow), - run_prompt_sem: Arc::clone(&self.run_prompt_sem), + prompt_sem: Arc::clone(&self.prompt_sem), audit_push: Arc::clone(&self.audit_push), peer_pid, sign_cache_context, @@ -886,7 +989,7 @@ impl Agent for VtSshAgentFactory { struct VtSshSession { keys: Arc>>, - last_activity: Arc>, + last_activity: Arc>, locked: Arc>, lock_passphrase: Arc>>, idle_cleared: Arc>, @@ -895,8 +998,8 @@ struct VtSshSession { /// Cloned per session for cheap reads; the underlying allowlist is /// constant for the lifetime of the agent process. run_allow: Arc, - /// Shared across all sessions so run@vt prompts serialize globally. - run_prompt_sem: Arc, + /// Shared across all sessions; see [`VtSshSession::authenticate_serialized`]. + prompt_sem: Arc, /// Shared fire-and-forget audit push config (disabled = no-op). audit_push: Arc, peer_pid: Option, @@ -1033,7 +1136,35 @@ impl VtSshSession { async fn touch_activity(&self) { let mut last = self.last_activity.write().await; - *last = Instant::now(); + *last = (Instant::now(), SystemTime::now()); + } + + /// Run the blocking auth chain (`authenticate`) for `reason`, serialized + /// through the global one-permit prompt semaphore and moved off the async + /// workers via `spawn_blocking`. + /// + /// Serialization: every accepted socket runs in its own tokio task, so + /// without the permit a peer holding VT_AUTH (or any process reaching a + /// forwarded socket) could stack N concurrent dialogs at the user — + /// prompt-fatigue that trains reflexive approval. `spawn_blocking`: + /// `authenticate` blocks on a human for up to ~30s; run inline it would + /// pin one runtime worker per concurrent prompt and, at worker-count + /// prompts, stall the whole agent including unrelated sessions. + async fn authenticate_serialized(&self, reason: &str) -> AuthOutcome { + // The semaphore is never closed, so acquire only fails if that ever + // changes; fail closed as "cannot prompt" rather than a user reject. + let Ok(_permit) = self.prompt_sem.acquire().await else { + return AuthOutcome::Unavailable(UnavailableReason::NotInteractive); + }; + let msg = reason.to_string(); + match tokio::task::spawn_blocking(move || authenticate(&msg)).await { + Ok(outcome) => outcome, + // Join error means the auth chain panicked — fail closed. + Err(e) => { + tracing::error!("auth prompt task failed: {}", e); + AuthOutcome::Unavailable(UnavailableReason::NotInteractive) + } + } } /// Check auth cache or prompt the user. Returns an [`AuthDecision`] @@ -1049,14 +1180,12 @@ impl VtSshSession { /// authorization purposes — a deliberate policy choice. async fn check_or_prompt_auth(&self, fingerprint: &str, auth_message: &str) -> AuthDecision { // If we couldn't resolve a cache context at session creation (no - // peer PID, no TTY, missing proc info), always prompt. `local_authentication` - // returns only a bool, so we cannot distinguish reject vs unavailable - // on this path — a false maps to Rejected. + // peer PID, no TTY, missing proc info), always prompt. let Some(context) = self.sign_cache_context else { - return if local_authentication(auth_message) { - AuthDecision::Approved - } else { - AuthDecision::Rejected + return match self.authenticate_serialized(auth_message).await { + AuthOutcome::Success(_) => AuthDecision::Approved, + AuthOutcome::Rejected => AuthDecision::Rejected, + AuthOutcome::Unavailable(_) => AuthDecision::Unavailable, }; }; @@ -1074,7 +1203,7 @@ impl VtSshSession { } // Prompt (no locks held) - let method = match authenticate(auth_message) { + let method = match self.authenticate_serialized(auth_message).await { AuthOutcome::Success(m) => m, AuthOutcome::Rejected => return AuthDecision::Rejected, AuthOutcome::Unavailable(reason) => { @@ -1106,8 +1235,8 @@ impl VtSshSession { /// Any legacy item in the batch disables caching for the whole batch — /// legacy items release plaintext, and the invariant "legacy-containing /// batch always prompts" is load-bearing for that decision. Otherwise: - /// full hit skips Touch ID; partial hit prompts once and grants only the - /// previously-missing items so existing strict TTLs are not refreshed. + /// full hit skips Touch ID; partial hit prompts once and then grants the + /// whole batch (strict-TTL — see the grant loop below). /// /// Returns a [`DecryptDecision`]: `CacheHit`/`Approved` allow the decrypt, /// `Rejected`/`Err(kind)` block it (the caller maps those to the structured @@ -1130,7 +1259,11 @@ impl VtSshSession { // (SessionLocked/NoGuiSession) is not flattened (NEW-1). Fail // closed: a `None` for a non-Success outcome (future enum variant) // becomes an Err rather than a silent allow. - _ => return decrypt_decision_from_authenticate(authenticate(auth_message)), + _ => { + return decrypt_decision_from_authenticate( + self.authenticate_serialized(auth_message).await, + ) + } }; let keys: Vec = v2_items @@ -1138,15 +1271,16 @@ impl VtSshSession { .map(|(t, salt)| decrypt_cache_key(*t, salt, host)) .collect(); - let missing: Vec = { + let miss_count = { + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); let cache = self.decrypt_auth_cache.read().await; keys.iter() - .filter(|k| !cache.is_authorized(context, k)) - .cloned() - .collect() + .filter(|k| !cache.is_authorized_at(context, k, now_mono, now_wall)) + .count() }; - if missing.is_empty() { + if miss_count == 0 { tracing::debug!( "Decrypt auth cache hit for context={:?} ({} v2 items)", context, @@ -1155,7 +1289,7 @@ impl VtSshSession { return DecryptDecision::CacheHit; } - let method = match authenticate(auth_message) { + let method = match self.authenticate_serialized(auth_message).await { AuthOutcome::Success(m) => m, AuthOutcome::Rejected => return DecryptDecision::Rejected, AuthOutcome::Unavailable(reason) => { @@ -1168,15 +1302,23 @@ impl VtSshSession { }; if method.is_cacheable() { + // The user just approved the ENTIRE batch, so grant every key in + // it, not just the previously-missing ones — an entry that was + // valid at the check above but expired while the prompt sat on + // screen would otherwise stay expired despite the fresh approval. + // `grant_at` is strict-TTL: keys still valid at this instant keep + // their original expiry (no sliding refresh). + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); let mut cache = self.decrypt_auth_cache.write().await; - for k in &missing { - cache.grant(context, k); + for k in &keys { + cache.grant_at(context, k, now_mono, now_wall); } tracing::debug!( - "Decrypt auth cache grant for context={:?} method={:?} new_entries={}", + "Decrypt auth cache grant for context={:?} method={:?} cache_misses={}", context, method, - missing.len() + miss_count ); } @@ -1262,6 +1404,13 @@ impl VtSshSession { if req.items.len() > MAX_CRYPTO_BATCH { return Err((ErrKind::BadRequest, Some(DETAIL_BATCH_TOO_LARGE))); } + // An empty batch has nothing to authorize; without this guard it + // would fall through to the uncached always-prompt path and put a + // "decrypt 0 secrets" dialog in front of the user — free prompt spam + // for any peer holding VT_AUTH. + if req.items.is_empty() { + return Err((ErrKind::BadRequest, Some(DETAIL_BATCH_EMPTY))); + } // Reject `SecretType::UNKNOWN` v2 items: serde would otherwise // accept them from a malformed `DecryptInput::V2`, the downstream @@ -1412,7 +1561,7 @@ impl VtSshSession { // forwarded agents, all remote sessions share the same local // process, so caching would approve all sudo from any session. let t0 = Instant::now(); - let outcome = authenticate(&auth_message); + let outcome = self.authenticate_serialized(&auth_message).await; let latency_ms = t0.elapsed().as_millis() as u64; let outcome_str = match &outcome { AuthOutcome::Success(_) => "approved", @@ -1512,27 +1661,18 @@ impl VtSshSession { } append_meta_lines(&mut auth_message, &req.meta); - // Serialize Touch ID prompts globally for run@vt. Without this, - // each accepted SSH-agent socket runs in its own tokio task and - // a remote attacker holding VT_AUTH could queue N concurrent - // prompts at the user. The permit is held across the (synchronous) - // `authenticate` call so prompts are presented one at a time. - // SAFETY: the semaphore is never `close`d, so `acquire` cannot - // fail; mapping the error to Generic is a defensive default. - let _permit = self.run_prompt_sem.acquire().await.map_err(|_| { - (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)) - })?; - // NEVER cache. run@vt is treated like auth@vt — every call prompts // because a forwarded agent socket is shared across all remote // sessions, and any cache here would let one session's approval // be reused by another to run arbitrary allowlisted programs. + // Prompt serialization (one dialog at a time) is inside + // `authenticate_serialized`, shared with every other prompting path. // run@vt: exe + argv joined as the audit `command` (reuses the prompt // builders above). All emit points carry it. let run_command = format!("exe: {}\nargv: {}", exe_display, argv_for_prompt); let run_reason = req.reason.as_deref().unwrap_or(""); let t0 = Instant::now(); - let outcome = authenticate(&auth_message); + let outcome = self.authenticate_serialized(&auth_message).await; let latency_ms = t0.elapsed().as_millis() as u64; match outcome { AuthOutcome::Success(_) => {} @@ -1647,7 +1787,7 @@ impl VtSshSession { // v1: ALWAYS PROMPT (no cache). Distinguish reject vs unavailable so the // client gets the right ErrKind (AuthRejected => no fallback, G3). let t0 = Instant::now(); - let outcome = authenticate(&auth_message); + let outcome = self.authenticate_serialized(&auth_message).await; let latency_ms = t0.elapsed().as_millis() as u64; // Audit the decision (op_kind="ssh-sign", same as the CF ceremony sign // path) — without this, agent-side `vt ssh connect` signing left no @@ -1776,6 +1916,7 @@ const DETAIL_RUN_ARGV_EMPTY: &str = "run@vt argv is empty"; const DETAIL_RUN_ARGV_TOO_LARGE: &str = "run@vt argv exceeds size cap"; const DETAIL_DISPLAY_FIELD_TOO_LARGE: &str = "display field exceeds size cap"; const DETAIL_BATCH_TOO_LARGE: &str = "batch exceeds the per-request item cap"; +const DETAIL_BATCH_EMPTY: &str = "batch contains no items"; const DETAIL_RUN_NOT_ALLOWLISTED: &str = "run@vt program is not in the agent's allowlist"; const DETAIL_RUN_SPAWN_FAILED: &str = "run@vt failed to spawn the program"; // sign@vt-specific failure reasons. All strings are static program info @@ -2240,17 +2381,37 @@ pub async fn run_ssh_agent( tracing::info!("run@vt enabled (allowlist configured)"); } - // Spawn idle sweeper that clears keys from memory after inactivity + // Spawn idle sweeper that clears keys from memory after inactivity. + // Judged on both clocks (see `idle_exceeded`) so time asleep counts. + // Clearing keys also clears both auth caches: "idle long enough to drop + // keys" implies the human is gone, so standing grants must not survive + // the silent keychain reload that serves the next request. let sweeper_keys = Arc::clone(&factory.keys); let sweeper_last = Arc::clone(&factory.last_activity); let sweeper_idle_cleared = Arc::clone(&factory.idle_cleared); + let sweeper_sign_cache = Arc::clone(&factory.sign_auth_cache); + let sweeper_decrypt_cache = Arc::clone(&factory.decrypt_auth_cache); let sweeper_timeout = idle_timeout; tokio::spawn(async move { let check_interval = Duration::from_secs(60).min(sweeper_timeout); loop { tokio::time::sleep(check_interval).await; - let last = *sweeper_last.read().await; - if last.elapsed() >= sweeper_timeout { + let (last_mono, last_wall) = *sweeper_last.read().await; + if idle_exceeded( + last_mono, + last_wall, + Instant::now(), + SystemTime::now(), + sweeper_timeout, + ) { + // Grants must not outlive the idle window even when no SSH + // keys are loaded (decrypt-only agents), so the cache flush + // is unconditional — not tied to the keys branch below. + let dropped = sweeper_sign_cache.write().await.clear() + + sweeper_decrypt_cache.write().await.clear(); + if dropped > 0 { + tracing::info!("Idle timeout, dropped {} auth cache grants", dropped); + } let mut keys = sweeper_keys.write().await; if !keys.is_empty() { let count = keys.len(); @@ -2267,16 +2428,55 @@ pub async fn run_ssh_agent( } }); - // Spawn auth cache sweeper (sweeps both sign and decrypt caches). + // Spawn the cache watcher: flushes both auth caches when the screen + // locks or the system wakes from sleep (sudo-timestamp semantics), and + // sweeps expired entries periodically. See `watcher_should_clear`. if sign_cache.mode != AuthCacheMode::None || decrypt_cache.mode != AuthCacheMode::None { - let sweeper_sign = Arc::clone(&factory.sign_auth_cache); - let sweeper_decrypt = Arc::clone(&factory.decrypt_auth_cache); + let watcher_sign = Arc::clone(&factory.sign_auth_cache); + let watcher_decrypt = Arc::clone(&factory.decrypt_auth_cache); tokio::spawn(async move { - let check_interval = Duration::from_secs(60); + // `None` while there's nothing cached: the WindowServer poll is + // skipped, so no lock-state history exists. On the first tick + // with entries the unknown history defaults to "was interactive" + // — grants can only be created under an interactive screen + // (`authenticate` pre-checks session state), so finding the + // screen locked with live entries means it locked after the + // grant and the transition must fire. + let mut was_interactive: Option = None; + let mut prev_mono = Instant::now(); + let mut prev_wall = SystemTime::now(); loop { - tokio::time::sleep(check_interval).await; - sweeper_sign.write().await.sweep_expired(); - sweeper_decrypt.write().await.sweep_expired(); + tokio::time::sleep(WATCHER_TICK).await; + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); + let empty = watcher_sign.read().await.is_empty() + && watcher_decrypt.read().await.is_empty(); + if empty { + // Nothing to flush — with 30-120s TTLs this is the common + // case, so skip the CGSession poll entirely. + was_interactive = None; + } else { + let is_interactive = super::security::session_interactive_now(); + if watcher_should_clear( + was_interactive.unwrap_or(true), + is_interactive, + now_mono.saturating_duration_since(prev_mono), + now_wall.duration_since(prev_wall).ok(), + ) { + let dropped = watcher_sign.write().await.clear() + + watcher_decrypt.write().await.clear(); + tracing::info!( + "Auth caches cleared on screen lock / wake ({} grants dropped)", + dropped + ); + } else { + watcher_sign.write().await.sweep_expired(); + watcher_decrypt.write().await.sweep_expired(); + } + was_interactive = Some(is_interactive); + } + prev_mono = now_mono; + prev_wall = now_wall; } }); tracing::info!( @@ -2601,44 +2801,9 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(cache.is_authorized(ctx, "fp1")); } - #[test] - fn test_auth_cache_grant_is_strict_ttl_idempotent() { - // A repeated grant on a still-valid entry must NOT extend its TTL — - // otherwise concurrent grants from racing sessions would silently - // refresh the entry and violate the strict-TTL invariant. - let mut cache = AuthCache::new(300); - let ctx = (1u64, 100u64); - cache.grant(ctx, "fp1"); - let first_expiry = *cache.entries.get(&(ctx, "fp1".to_string())).unwrap(); - - // Sleep long enough that a second grant would compute a strictly - // later expiry if it were allowed to overwrite. - std::thread::sleep(std::time::Duration::from_millis(20)); - cache.grant(ctx, "fp1"); - let second_expiry = *cache.entries.get(&(ctx, "fp1".to_string())).unwrap(); - assert_eq!( - first_expiry, second_expiry, - "valid entry's TTL must not be refreshed by a repeat grant" - ); - } - - #[test] - fn test_auth_cache_grant_replaces_expired_entry() { - // An expired entry (still in the map because the sweeper hasn't run - // yet) must be replaced by a fresh grant — otherwise a successful - // re-auth after expiry would silently fail to populate the cache. - let mut cache = AuthCache::new(0); - let ctx = (1u64, 100u64); - cache.grant(ctx, "fp1"); - std::thread::sleep(std::time::Duration::from_millis(10)); - assert!(!cache.is_authorized(ctx, "fp1"), "entry should be expired"); - - // Switch to a non-zero TTL and re-grant; the entry is replaced with - // a fresh expiry. - cache.ttl = std::time::Duration::from_secs(300); - cache.grant(ctx, "fp1"); - assert!(cache.is_authorized(ctx, "fp1")); - } + // (Strict-TTL idempotence and expired-entry replacement are covered + // deterministically by the clock-injected `_at` tests below — no + // sleep-based duplicates.) #[test] fn test_auth_cache_different_context_misses() { @@ -2665,17 +2830,6 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(!cache.is_authorized(reused, "fp1")); } - #[test] - fn test_auth_cache_expiry() { - let mut cache = AuthCache::new(0); // 0 second duration = immediately expired - let ctx = (1u64, 100u64); - cache.grant(ctx, "fp1"); - - // With 0 duration, entries expire immediately - std::thread::sleep(std::time::Duration::from_millis(10)); - assert!(!cache.is_authorized(ctx, "fp1")); - } - #[test] fn test_auth_cache_clear() { let mut cache = AuthCache::new(300); @@ -2701,6 +2855,197 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(cache.entries.is_empty()); } + // --- Dual-clock expiry tests (sleep / clock-step scenarios) --- + // + // Clock-injected regression tests for the grant-survives-sleep bug; + // see [`CacheExpiry`] for the CLOCK_UPTIME_RAW rationale. + + #[test] + fn test_auth_cache_wall_clock_expires_grant_across_sleep() { + let mut cache = AuthCache::new(120); + let ctx = (1u64, 100u64); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + cache.grant_at(ctx, "fp1", m0, w0); + + // 1s of awake time, but 10 minutes of wall time passed (slept). + let asleep_mono = m0 + Duration::from_secs(1); + let asleep_wall = w0 + Duration::from_secs(600); + assert!( + !cache.is_authorized_at(ctx, "fp1", asleep_mono, asleep_wall), + "grant must not survive a sleep longer than the TTL" + ); + } + + #[test] + fn test_auth_cache_mono_clock_bounds_stalled_wall_clock() { + // Wall clock stalled or stepped backwards (NTP): the monotonic bound + // still caps total awake time at the TTL. + let mut cache = AuthCache::new(120); + let ctx = (1u64, 100u64); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + cache.grant_at(ctx, "fp1", m0, w0); + + let late_mono = m0 + Duration::from_secs(121); + let early_wall = w0 + Duration::from_secs(1); + assert!( + !cache.is_authorized_at(ctx, "fp1", late_mono, early_wall), + "a backwards/stalled wall clock must not extend the grant" + ); + } + + #[test] + fn test_auth_cache_valid_while_both_clocks_within_ttl() { + let mut cache = AuthCache::new(120); + let ctx = (1u64, 100u64); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + cache.grant_at(ctx, "fp1", m0, w0); + + assert!(cache.is_authorized_at( + ctx, + "fp1", + m0 + Duration::from_secs(60), + w0 + Duration::from_secs(60), + )); + } + + #[test] + fn test_auth_cache_strict_ttl_not_extended_by_midway_regrant() { + // A re-grant at TTL/2 (e.g. racing prompt resolved late) must not + // move the original expiry. + let mut cache = AuthCache::new(120); + let ctx = (1u64, 100u64); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + cache.grant_at(ctx, "fp1", m0, w0); + cache.grant_at( + ctx, + "fp1", + m0 + Duration::from_secs(60), + w0 + Duration::from_secs(60), + ); + + assert!( + !cache.is_authorized_at( + ctx, + "fp1", + m0 + Duration::from_secs(121), + w0 + Duration::from_secs(121), + ), + "midway re-grant must not extend the original expiry" + ); + } + + #[test] + fn test_auth_cache_regrant_after_wall_expiry_refreshes() { + // Fresh approval after a sleep-induced expiry must produce a live + // entry (models the decrypt batch grant-all-keys path where an entry + // expired while the prompt sat on screen). + let mut cache = AuthCache::new(120); + let ctx = (1u64, 100u64); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + cache.grant_at(ctx, "fp1", m0, w0); + + // Slept past the TTL, then the user re-approved. + let m1 = m0 + Duration::from_secs(1); + let w1 = w0 + Duration::from_secs(600); + assert!(!cache.is_authorized_at(ctx, "fp1", m1, w1)); + cache.grant_at(ctx, "fp1", m1, w1); + assert!(cache.is_authorized_at( + ctx, + "fp1", + m1 + Duration::from_secs(1), + w1 + Duration::from_secs(1), + )); + } + + // --- idle_exceeded tests --- + + #[test] + fn test_idle_exceeded_counts_sleep_via_wall_clock() { + // 1s of awake time but 31 minutes of wall time → idle (the mono-only + // check would have kept keys in memory forever on a nap-happy laptop). + let m0 = Instant::now(); + let w0 = SystemTime::now(); + let timeout = Duration::from_secs(30 * 60); + assert!(idle_exceeded( + m0, + w0, + m0 + Duration::from_secs(1), + w0 + Duration::from_secs(31 * 60), + timeout, + )); + } + + #[test] + fn test_idle_not_exceeded_when_recently_active() { + let m0 = Instant::now(); + let w0 = SystemTime::now(); + let timeout = Duration::from_secs(30 * 60); + assert!(!idle_exceeded( + m0, + w0, + m0 + Duration::from_secs(60), + w0 + Duration::from_secs(60), + timeout, + )); + } + + #[test] + fn test_idle_exceeded_via_mono_despite_backwards_wall() { + // Wall clock stepped backwards (duration_since errors → treated as + // zero); the monotonic clock alone must still drive the timeout. + let m0 = Instant::now(); + let w0 = SystemTime::now() + Duration::from_secs(3600); + let timeout = Duration::from_secs(30 * 60); + assert!(idle_exceeded( + m0, + w0, + m0 + Duration::from_secs(31 * 60), + SystemTime::now(), + timeout, + )); + } + + // --- watcher_should_clear tests --- + + #[test] + fn test_watcher_clears_on_lock_transition() { + let tick = Duration::from_secs(5); + assert!(watcher_should_clear(true, false, tick, Some(tick))); + } + + #[test] + fn test_watcher_does_not_clear_on_steady_states_or_unlock() { + let tick = Duration::from_secs(5); + // Steady locked: the transition already cleared; don't churn. + assert!(!watcher_should_clear(false, false, tick, Some(tick))); + // Steady interactive: nothing happened. + assert!(!watcher_should_clear(true, true, tick, Some(tick))); + // Unlock: fresh grants after unlock must not be eaten. + assert!(!watcher_should_clear(false, true, tick, Some(tick))); + } + + #[test] + fn test_watcher_clears_on_sleep_divergence() { + // Wall advanced 5s (tick) + 35s more than mono → system slept. + let mono = Duration::from_secs(5); + let wall = Some(Duration::from_secs(40)); + assert!(watcher_should_clear(true, true, mono, wall)); + } + + #[test] + fn test_watcher_ignores_small_divergence_and_backwards_wall() { + let mono = Duration::from_secs(5); + // Small NTP adjustment below the threshold: no clear. + assert!(!watcher_should_clear(true, true, mono, Some(Duration::from_secs(20)))); + // Wall stepped backwards (None): not a sleep signal. + assert!(!watcher_should_clear(true, true, mono, None)); + } + // --- decrypt_cache_key tests --- #[test] From 9027185a9aebb851251a81beabc2cfecf01ace69 Mon Sep 17 00:00:00 2001 From: qiqi Date: Mon, 13 Jul 2026 18:22:01 +0800 Subject: [PATCH 2/4] feat(agent): global cache mode, prompt-queue cache re-check, cache-hit notices; fix macOS shim recursion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes, each closing a gap found while dogfooding the auth cache: - AuthCacheMode::Global: one shared context for the whole agent, no TTY or session requirement. Orchestrated callers (AI agents / CI / make) spawn TTY-less commands with a fresh session per call, so per-session/per-app resolve to None and can never cache; global is the opt-in escape hatch for exactly that workload. Coarsest blast radius (any socket reacher within the TTL, incl. forwarded sessions) — documented in CLAUDE.md and the CLI help. - Prompt-queue cache double-check: cached paths now re-check the cache after acquiring the global prompt permit, and grants land while the permit is still held. N concurrent requests for the same key/records collapse to one Touch ID; the waiters resolve as silent cache hits instead of stacking N sequential dialogs. - Agent cache-hit 免审批 notice (cf-worker): audit-ingest rows with outcome=cache_hit now fan out the same one-line notice as the Worker DEK-cache hit (Pushover / Slack / Slack App / Feishu), with the note 「缓存命中,免 Touch ID」, throttled DO-side to one per (op_kind, host) per 60s. The dispatch is a shared pushCacheHitNotices used by both the DEK-cache and agent paths; buildCacheHitLines gains an optional note and drops the count segment when salts=0. - fix(hook): vt shims looped to the recursion limit on macOS. current_exe() there returns the invoked symlink path (unlike Linux's /proc/self/exe), so basename(vt_binary()) equalled the shimmed tool name, the "bare vt runs as-is" guard matched every shim, and the bare-name exec resolved straight back to the shim. vt_binary() now canonicalizes (canonical_self_exe); and resolve_real fails fast with a clear error when every PATH candidate is a vt shim instead of letting execvp loop. --- CLAUDE.md | 20 +++- cf-worker/src/do_account.ts | 69 +++++++++++--- cf-worker/src/feishu.ts | 3 +- cf-worker/src/notify.ts | 15 ++- cf-worker/src/slack_app.ts | 3 +- src/hook.rs | 107 +++++++++++++++++----- src/main.rs | 4 +- src/server_macos/ssh_agent.rs | 168 ++++++++++++++++++++++++++-------- 8 files changed, 304 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b60a0be..6a36be7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,15 +158,29 @@ default to `none` (every request prompts): Modes: `per-session` keys on the terminal session leader (`getsid` + start time); `per-app` keys on the nearest `.app/Contents/` ancestor (all tabs of one -terminal app share a context). Peers without a controlling TTY never cache. -`auth@vt` / `run@vt` NEVER cache. +terminal app share a context). Both require the caller to have a controlling +TTY — **orchestrated callers (AI agents / CI / make) spawn TTY-less commands +with a fresh session per call, so they can never hit these modes.** `global` is +the escape hatch for that case: ONE shared context for the whole agent, no TTY +requirement — the coarsest blast radius (within the TTL any socket reacher, +including every forwarded session, rides one grant; only the TTL and the +lock/wake/idle flushes bound it). `auth@vt` / `run@vt` NEVER cache. Expiry is tracked on **both** the monotonic and the wall clock (macOS `Instant` freezes during sleep — either clock passing the TTL kills the entry), grants are strict-TTL (no sliding refresh), and both caches are flushed on: agent `lock`, screen lock, wake-from-sleep (detected via clock divergence), and idle key clearing. All prompts are serialized through a global one-permit semaphore so a -hostile peer cannot stack dialogs. +hostile peer cannot stack dialogs; cached paths **re-check the cache after the +queue wait** (grants land while the permit is still held), so an N-request +burst for the same key/records costs one Touch ID, and the waiters resolve as +silent cache hits. + +When audit push is configured (`--audit-url`/`--audit-key`), every agent-side +cache hit also triggers a **免审批 notice** on the same notification channels +as the Worker DEK-cache hit (Pushover / Slack / Slack App / Feishu), with the +note 「缓存命中,免 Touch ID」. Throttled Worker-side to one notice per +(op_kind, host) per 60s — the audit table still records every hit. **Forwarded-agent hazard (deliberate tradeoff, off by default):** the cache context is derived from the *local* peer process. Requests arriving over a diff --git a/cf-worker/src/do_account.ts b/cf-worker/src/do_account.ts index 3a3a2c6..9c0204f 100644 --- a/cf-worker/src/do_account.ts +++ b/cf-worker/src/do_account.ts @@ -35,6 +35,10 @@ const TTL_MS = 5 * 60 * 1000; const RETENTION_MS = 10 * 60 * 1000; // Audit rows (ceremony + cache events) are kept 90 days, then swept by the alarm. const AUDIT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +// Agent Touch-ID-cache hits inside a TTL window can arrive many times a minute +// (orchestrated callers); notify at most once per key per this interval. The +// audit table still records every hit — the notice is a heads-up, not a ledger. +const AGENT_CACHE_NOTIFY_MIN_INTERVAL_MS = 60 * 1000; // Allowed positive DEK-cache TTLs (seconds). A write with any value outside // this set is rejected, so neither a tampered approve body nor a future UI typo @@ -129,6 +133,9 @@ export class AccountDO extends DurableObject { // Monotonic audit change counter. Seeded from MAX(seq) in the constructor so // it survives DO eviction (every write persists seq), then ++'d per write. private seqCounter = 0; + // Last agent-cache-hit notice per `${op_kind}|${host}` (epoch ms). In-memory + // only: resets on DO eviction/hibernation — worst case one extra notice. + private agentCacheNotifyMs = new Map(); constructor(state: DurableObjectState, env: Env) { super(state, env); @@ -1174,33 +1181,46 @@ export class AccountDO extends DurableObject { log('cache.hit', { n: salts.length, ip, ppid }); // Real-time notice: a cache hit serves a decrypt with NO phone in the loop, - // so push the same opt-in channels used for approvals. Fire-and-forget via - // waitUntil — delivery is best-effort and must never delay or fail the DEK - // response (the audit row above is the durable record). + // so push the same opt-in channels used for approvals. Fire-and-forget — + // delivery is best-effort and must never delay or fail the DEK response + // (the audit row above is the durable record). + this.pushCacheHitNotices(meta, salts.length, undefined, 'cachehit_failed'); + return Response.json({ source: 'cache', sealed_deks_b64u: sealedB64u } satisfies DekCacheResponse); + } + + // Fan a cache-hit notice out to every configured channel (stateless + // Pushover/Slack-webhook fanOut + Feishu + Slack App), each via waitUntil — + // compact, no @, no edit lifecycle (terminal FYI). Shared by the Worker + // DEK-cache hit (opDekCache) and the agent Touch-ID-cache hit + // (notifyAgentCacheHit); `note` names the skipped factor when it isn't the + // default phone approval, `errTag` distinguishes the two sources in logs. + private pushCacheHitNotices( + meta: ChallengeMeta, + salts: number, + note: string | undefined, + errTag: string, + ): void { this.ctx.waitUntil( - notifyCacheHit(this.env, meta, salts.length) - .then((w) => { if (w) logErr('notify.cachehit_failed', w); }) - .catch((e) => logErr('notify.cachehit_failed', e)), + notifyCacheHit(this.env, meta, salts, note) + .then((w) => { if (w) logErr(`notify.${errTag}`, w); }) + .catch((e) => logErr(`notify.${errTag}`, e)), ); - // Feishu / Slack App cache-hit notice — compact, no @, no edit lifecycle - // (terminal FYI). const feishu = this.feishuCfg(); if (feishu) { this.ctx.waitUntil( - sendCacheHitNotice(feishu, this.feishuKv(), Date.now(), meta, salts.length) - .then((w) => { if (w) logErr('feishu.cachehit_failed', w); }) - .catch((e) => logErr('feishu.cachehit_failed', e)), + sendCacheHitNotice(feishu, this.feishuKv(), Date.now(), meta, salts, note) + .then((w) => { if (w) logErr(`feishu.${errTag}`, w); }) + .catch((e) => logErr(`feishu.${errTag}`, e)), ); } const slackApp = this.slackAppCfg(); if (slackApp) { this.ctx.waitUntil( - sendSlackAppCacheHitNotice(slackApp, meta, salts.length) - .then((w) => { if (w) logErr('slackapp.cachehit_failed', w); }) - .catch((e) => logErr('slackapp.cachehit_failed', e)), + sendSlackAppCacheHitNotice(slackApp, meta, salts, note) + .then((w) => { if (w) logErr(`slackapp.${errTag}`, w); }) + .catch((e) => logErr(`slackapp.${errTag}`, e)), ); } - return Response.json({ source: 'cache', sealed_deks_b64u: sealedB64u } satisfies DekCacheResponse); } // Ingest one SSH-agent audit record. The Worker has already verified the @@ -1216,9 +1236,28 @@ export class AccountDO extends DurableObject { return badRequest('invalid audit op'); } this.auditAgent(op); + // An agent cache hit (sign / decrypt@vt served from the Touch ID auth + // cache) had no human in the loop, so surface it on the same channels as + // the Worker DEK-cache 免审批 notice. Throttled; fire-and-forget. + if (op.outcome === 'cache_hit') this.notifyAgentCacheHit(op); return Response.json({ ok: true }); } + // Throttled 免审批 notice for an agent-side cache hit; the actual dispatch + // is the shared pushCacheHitNotices. The note names the skipped factor — + // Touch ID here, not a phone approval. + private notifyAgentCacheHit(op: DoAuditIngestOp): void { + const key = `${op.meta.op_kind}|${op.meta.host}`; + const now = Date.now(); + if (now - (this.agentCacheNotifyMs.get(key) ?? 0) < AGENT_CACHE_NOTIFY_MIN_INTERVAL_MS) return; + // Bound the map: keys are (op_kind, host) pairs, so growth needs a hostile + // agent minting hostnames — cheap to cap anyway. + if (this.agentCacheNotifyMs.size > 256) this.agentCacheNotifyMs.clear(); + this.agentCacheNotifyMs.set(key, now); + + this.pushCacheHitNotices(op.meta, op.salts, '缓存命中,免 Touch ID', 'agent_cachehit_failed'); + } + // Admin: clear the cached DEKs written by ONE approval, identified by its // audit token_id (cache entries store origin_token_id = the approval's // token_id). Powers the per-row "清除缓存" button on the audit page. diff --git a/cf-worker/src/feishu.ts b/cf-worker/src/feishu.ts index c574478..88ed632 100644 --- a/cf-worker/src/feishu.ts +++ b/cf-worker/src/feishu.ts @@ -329,10 +329,11 @@ export async function sendCacheHitNotice( now: number, meta: Pick, salts: number, + note?: string, ): Promise { // Reuse the shared cache-hit builder so the card title + lines never drift // from the Pushover/Slack text. - const { title, lines } = buildCacheHitLines(meta, salts); + const { title, lines } = buildCacheHitLines(meta, salts, note); const card = { config: { wide_screen_mode: true }, // plain_text (NOT lark_md): command/pwd are caller-supplied, so rendering as diff --git a/cf-worker/src/notify.ts b/cf-worker/src/notify.ts index 23dee85..5293143 100644 --- a/cf-worker/src/notify.ts +++ b/cf-worker/src/notify.ts @@ -59,16 +59,21 @@ export function buildApprovalMessage( // ssh/ip/reason lines and leads with a one-line summary (who · N records · cache // note), then the two highest-signal fields pwd + cmd (what ran, and where). No // @-mention anywhere on this path. The durable audit row keeps the full context. +// `note` names the skipped factor: the default fits the Worker DEK cache +// (no phone approval); the agent's Touch-ID-cache ingest path passes its own +// (免 Touch ID). `salts` of 0 (e.g. an agent `sign` hit has no records) drops +// the count segment rather than printing "0 条". export function buildCacheHitLines( meta: Pick, salts: number, + note = '缓存命中,无手机审批', ): { title: string; lines: string[] } { const title = meta.op_kind ? `VT 缓存命中(免审批): ${meta.op_kind}` : 'VT 缓存命中(免审批解密)'; const who = [meta.user, meta.host].filter(Boolean).join('@'); const head: string[] = []; if (who) head.push(who); - head.push(`${salts} 条`); - head.push('缓存命中,无手机审批'); + if (salts > 0) head.push(`${salts} 条`); + head.push(note); const lines: string[] = [head.join(' · ')]; if (meta.pwd) lines.push(`pwd: ${meta.pwd}`); // Same self-labelled-multi-line handling as metaLines above. @@ -79,8 +84,9 @@ export function buildCacheHitLines( export function buildCacheHitMessage( meta: Pick, salts: number, + note?: string, ): { title: string; body: string } { - const { title, lines } = buildCacheHitLines(meta, salts); + const { title, lines } = buildCacheHitLines(meta, salts, note); return { title, body: lines.join('\n') }; } @@ -152,7 +158,8 @@ export async function notifyCacheHit( env: Env, meta: Pick, salts: number, + note?: string, ): Promise { - const { title, body } = buildCacheHitMessage(meta, salts); + const { title, body } = buildCacheHitMessage(meta, salts, note); return fanOut(env, title, body); } diff --git a/cf-worker/src/slack_app.ts b/cf-worker/src/slack_app.ts index 4fb2ff5..a106b91 100644 --- a/cf-worker/src/slack_app.ts +++ b/cf-worker/src/slack_app.ts @@ -280,10 +280,11 @@ export async function sendCacheHitNotice( cfg: SlackAppConfig, meta: Pick, salts: number, + note?: string, ): Promise { // Reuse the shared cache-hit builder so the title + lines never drift from the // Pushover/Slack/Feishu text. - const { title, lines } = buildCacheHitLines(meta, salts); + const { title, lines } = buildCacheHitLines(meta, salts, note); const res = await apiCall('chat.postMessage', { channel: cfg.channel, text: title, diff --git a/src/hook.rs b/src/hook.rs index db5f4ff..43a33d7 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -388,7 +388,7 @@ fn run_exec(cfg: &HookConfig, vt_bin: &str, argv: &[String]) -> Result<()> { let cwd = current_dir(); match decide(&prog, &args, &cwd, cfg, &env_lookup) { Decision::Allow => { - let real = resolve_real(arg0); + let real = resolve_real(arg0)?; exec_real(&real, rest) } Decision::Deny(reason) => { @@ -402,7 +402,7 @@ fn run_exec(cfg: &HookConfig, vt_bin: &str, argv: &[String]) -> Result<()> { for (k, v) in &plan.set_vars { std::env::set_var(k, v); } - let real = resolve_real(arg0); + let real = resolve_real(arg0)?; if !plan.needs_inject() { // Only plaintext values to supply — exec the command directly. return exec_real(&real, rest); @@ -433,25 +433,46 @@ fn exec_real(prog: &str, args: &[String]) -> Result<()> { /// back at `vt`). This is how re-execing never re-enters a shim — and it is /// robust to symlinked PATH entries (`/home/me` → `/essd/me`) because it /// compares the *resolved target*, not the directory string. A name already -/// containing `/` is returned unchanged; unresolvable → returned as-is (let -/// `exec` fail with ENOENT). -fn resolve_real(arg0: &str) -> String { +/// containing `/` is returned unchanged. +/// +/// When every PATH candidate IS a vt shim, this is a hard error: exec'ing the +/// bare name would go through execvp's own PATH search, land on the shim +/// again, and loop to the depth limit — the confusing failure mode this +/// replaces. A name with no candidates at all is returned as-is (let `exec` +/// fail with ENOENT). +fn resolve_real(arg0: &str) -> Result { if arg0.contains('/') { - return arg0.to_string(); + return Ok(arg0.to_string()); } - let self_exe = std::env::current_exe() - .ok() - .and_then(|p| std::fs::canonicalize(p).ok()); + let self_exe = canonical_self_exe(); let paths: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - resolve_in_paths(arg0, &paths, self_exe.as_deref()).unwrap_or_else(|| arg0.to_string()) + match resolve_in_paths(arg0, &paths, self_exe.as_deref()) { + Resolved::Real(p) => Ok(p), + Resolved::OnlyShims => anyhow::bail!( + "vt hook exec: no real `{arg0}` on PATH — every candidate is a vt shim. \ + Install `{arg0}` (or put its real location on PATH), or remove the shim." + ), + Resolved::NotFound => Ok(arg0.to_string()), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Resolved { + /// First executable PATH candidate that is not a vt shim. + Real(String), + /// Executable candidates exist, but every one canonicalizes to vt itself. + OnlyShims, + /// No executable candidate at all. + NotFound, } /// First `dir/arg0` on `paths` that is executable and is NOT `self_exe` (our own /// binary, i.e. a vt shim symlink). Canonicalizing the candidate resolves the /// symlink to the real vt binary, so shims are detected regardless of path form. -fn resolve_in_paths(arg0: &str, paths: &[PathBuf], self_exe: Option<&Path>) -> Option { +fn resolve_in_paths(arg0: &str, paths: &[PathBuf], self_exe: Option<&Path>) -> Resolved { + let mut skipped_shim = false; for dir in paths { let cand = dir.join(arg0); if !is_executable(&cand) { @@ -462,11 +483,16 @@ fn resolve_in_paths(arg0: &str, paths: &[PathBuf], self_exe: Option<&Path>) -> O None => false, }; if is_self { + skipped_shim = true; continue; // this candidate is a vt shim → skip past it } - return Some(cand.to_string_lossy().into_owned()); + return Resolved::Real(cand.to_string_lossy().into_owned()); + } + if skipped_shim { + Resolved::OnlyShims + } else { + Resolved::NotFound } - None } fn is_executable(p: &Path) -> bool { @@ -615,16 +641,35 @@ fn args_prefix_matches(rule_args: &[String], inv_args: &[String]) -> bool { .all(|(want, got)| want == got) } +/// This process's executable, canonicalized. +/// +/// Canonicalization is load-bearing: when running as a shim (a symlink to vt +/// named after the shimmed tool), macOS's `current_exe()` returns the symlink +/// path as invoked (`_NSGetExecutablePath` does not resolve links, unlike +/// Linux's `/proc/self/exe`). Un-canonicalized, `basename(vt_bin)` would +/// equal the tool name itself, so run_exec's "bare vt runs as-is" recursion +/// guard would match EVERY shimmed command and exec the bare name — which +/// execvp resolves right back to the shim, looping to the depth limit; inject +/// rewrites would similarly re-enter the shim instead of `vt inject`. The +/// same resolved identity is what `resolve_in_paths` compares candidates +/// against to skip shims. +fn canonical_self_exe() -> Option { + std::env::current_exe() + .ok() + .map(|p| std::fs::canonicalize(&p).unwrap_or(p)) +} + /// The vt binary to invoke in rewrites. Prefer `$VT_HOOK_BIN`, then this -/// process's own absolute path (guarantees the same binary), else bare `vt`. +/// process's own canonicalized path (guarantees the same binary — see +/// `canonical_self_exe` for why the raw `current_exe` path is NOT usable +/// here), else bare `vt`. fn vt_binary() -> String { if let Ok(p) = std::env::var("VT_HOOK_BIN") { if !p.trim().is_empty() { return p; } } - std::env::current_exe() - .ok() + canonical_self_exe() .and_then(|p| p.to_str().map(String::from)) .unwrap_or_else(|| "vt".to_string()) } @@ -1046,18 +1091,36 @@ mod tests { let paths = vec![link.join("shim"), real.clone()]; // With self set → the shim (symlink to vt) is skipped, real gh wins. - let got = resolve_in_paths("gh", &paths, Some(&vt_canon)).unwrap(); - assert_eq!(got, real_gh.to_string_lossy(), "vt shim symlink must be skipped"); + let got = resolve_in_paths("gh", &paths, Some(&vt_canon)); + assert_eq!( + got, + Resolved::Real(real_gh.to_string_lossy().into_owned()), + "vt shim symlink must be skipped" + ); // Without self → first candidate wins (the shim), proving self is the guard. - let got2 = resolve_in_paths("gh", &paths, None).unwrap(); - assert_eq!(got2, link.join("shim").join("gh").to_string_lossy()); - // Missing command → None. - assert!(resolve_in_paths("nope", &paths, Some(&vt_canon)).is_none()); + let got2 = resolve_in_paths("gh", &paths, None); + assert_eq!( + got2, + Resolved::Real(link.join("shim").join("gh").to_string_lossy().into_owned()) + ); + // Missing command → NotFound. + assert_eq!(resolve_in_paths("nope", &paths, Some(&vt_canon)), Resolved::NotFound); + // Shim is the ONLY candidate → OnlyShims (a hard error upstream): exec'ing + // the bare name would execvp straight back into the shim and loop. + let shim_only = vec![link.join("shim")]; + assert_eq!( + resolve_in_paths("gh", &shim_only, Some(&vt_canon)), + Resolved::OnlyShims + ); std::fs::remove_file(&link).ok(); std::fs::remove_dir_all(&base).ok(); } + // (The macOS current_exe-returns-the-symlink regression is guarded by + // canonical_self_exe's doc + the OnlyShims coverage above; a unit test + // here could only re-assert stdlib canonicalize semantics.) + #[test] fn decide_returns_structured_inject_plan() { let env = EnvConfig { diff --git a/src/main.rs b/src/main.rs index dd1ce30..db095c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -314,7 +314,7 @@ pub enum SshCommands { #[arg( long = "ssh-auth-cache-mode", default_value = "none", - help = "Sign auth cache mode: none, per-session, or per-app. WARNING: the cache context is the LOCAL terminal session/app — requests arriving over a forwarded agent socket (ssh -A) from a remote host share it, so within the TTL a hostile remote process can sign silently. Keep 'none' if you forward this agent to hosts you don't fully trust." + help = "Sign auth cache mode: none, per-session, per-app, or global. per-session/per-app require the caller to have a controlling terminal (TTY-less orchestrators like AI agents / CI never hit); global shares ONE context across all callers — the only mode that serves orchestrated callers, and the coarsest. WARNING: cache contexts are LOCAL — requests arriving over a forwarded agent socket (ssh -A) from a remote host share them, so within the TTL a hostile remote process can sign silently. Keep 'none' if you forward this agent to hosts you don't fully trust." )] auth_cache_mode: server_macos::ssh_agent::AuthCacheMode, #[arg( @@ -326,7 +326,7 @@ pub enum SshCommands { #[arg( long = "decrypt-auth-cache-mode", default_value = "none", - help = "Decrypt auth cache mode: none, per-session, or per-app. Only v2 envelope URLs are cache-eligible; legacy items always prompt. Same forwarded-agent caveat as --ssh-auth-cache-mode." + help = "Decrypt auth cache mode: none, per-session, per-app, or global. Only v2 envelope URLs are cache-eligible; legacy items always prompt. Same mode semantics and forwarded-agent caveat as --ssh-auth-cache-mode." )] decrypt_auth_cache_mode: server_macos::ssh_agent::AuthCacheMode, #[arg( diff --git a/src/server_macos/ssh_agent.rs b/src/server_macos/ssh_agent.rs index c79569c..4a6da44 100644 --- a/src/server_macos/ssh_agent.rs +++ b/src/server_macos/ssh_agent.rs @@ -91,6 +91,13 @@ pub enum AuthCacheMode { None, PerSession, PerApp, + /// One shared context for the whole agent process — no TTY or session + /// requirement. The only mode that serves orchestrated callers (AI + /// agents, CI, make) whose spawned commands have no controlling terminal + /// and a fresh session per call, so per-session/per-app can never hit. + /// Coarsest blast radius: within the TTL, ANY caller that can reach the + /// socket (including every session of a forwarded agent) rides one grant. + Global, } impl FromStr for AuthCacheMode { @@ -101,8 +108,9 @@ impl FromStr for AuthCacheMode { "none" => Ok(AuthCacheMode::None), "per-session" | "per_session" | "session" => Ok(AuthCacheMode::PerSession), "per-app" | "per_app" | "app" => Ok(AuthCacheMode::PerApp), + "global" => Ok(AuthCacheMode::Global), _ => Err(format!( - "invalid auth cache mode '{}': expected none, per-session, or per-app", + "invalid auth cache mode '{}': expected none, per-session, per-app, or global", s )), } @@ -115,6 +123,7 @@ impl std::fmt::Display for AuthCacheMode { AuthCacheMode::None => write!(f, "none"), AuthCacheMode::PerSession => write!(f, "per-session"), AuthCacheMode::PerApp => write!(f, "per-app"), + AuthCacheMode::Global => write!(f, "global"), } } } @@ -929,15 +938,21 @@ impl VtSshAgentFactory { /// (the shell at the head of the pty — see `proc_info::get_sid`) lives for the /// full terminal session, giving the cache a stable anchor. `tdev != 0` is /// still required so daemons without a controlling terminal stay uncacheable. +/// +/// `Global` is the escape hatch for orchestrated callers (AI agents / CI / +/// make) whose spawned commands have **no controlling terminal and a fresh +/// session per call** — under per-session/per-app they resolve to `None` and +/// can never hit. It maps every peer to one fixed context; the only remaining +/// boundary is the TTL (plus the lock/wake/idle flushes). fn resolve_cache_context( peer_pid: Option, mode: AuthCacheMode, ) -> Option { let pid = peer_pid?; - // Both cache modes require the peer to have a controlling terminal. + // Per-session / per-app require the peer to have a controlling terminal. // launchd-managed daemons and other non-interactive peers always prompt - // and never enter the cache — caching only makes sense for explicit - // human-driven terminal sessions. + // and never enter the cache — for these modes caching only makes sense + // for explicit human-driven terminal sessions. if matches!(mode, AuthCacheMode::PerSession | AuthCacheMode::PerApp) && proc_info::get_tty_dev(pid).is_none() { @@ -955,6 +970,9 @@ fn resolve_cache_context( let start = proc_info::get_start_tvsec(app_pid)?; Some((app_pid as u64, start)) } + // (0, 0) can never collide with a real context: sid/app_pid are + // always > 0. + AuthCacheMode::Global => Some((0, 0)), } } @@ -1057,6 +1075,20 @@ impl DecryptDecision { } } +/// Run the blocking auth chain on the blocking pool. No serialization — +/// callers must already hold the prompt permit (or deliberately not need +/// one). Join error means the auth chain panicked; fail closed. +async fn authenticate_on_blocking_pool(reason: &str) -> AuthOutcome { + let msg = reason.to_string(); + match tokio::task::spawn_blocking(move || authenticate(&msg)).await { + Ok(outcome) => outcome, + Err(e) => { + tracing::error!("auth prompt task failed: {}", e); + AuthOutcome::Unavailable(UnavailableReason::NotInteractive) + } + } +} + /// Map a single `authenticate()` outcome (the always-prompt, no-cache path) /// onto a `DecryptDecision`, preserving the structured `ErrKind` for the /// failure arms. `Rejected` is its own variant; a `None` from @@ -1150,21 +1182,18 @@ impl VtSshSession { /// `authenticate` blocks on a human for up to ~30s; run inline it would /// pin one runtime worker per concurrent prompt and, at worker-count /// prompts, stall the whole agent including unrelated sessions. + /// + /// The cached paths (`check_or_prompt_auth`, + /// `check_or_prompt_decrypt_batch`) don't use this wrapper: they acquire + /// the permit themselves so they can re-check the cache after the queue + /// wait. Use this only where no cache is involved. async fn authenticate_serialized(&self, reason: &str) -> AuthOutcome { // The semaphore is never closed, so acquire only fails if that ever // changes; fail closed as "cannot prompt" rather than a user reject. let Ok(_permit) = self.prompt_sem.acquire().await else { return AuthOutcome::Unavailable(UnavailableReason::NotInteractive); }; - let msg = reason.to_string(); - match tokio::task::spawn_blocking(move || authenticate(&msg)).await { - Ok(outcome) => outcome, - // Join error means the auth chain panicked — fail closed. - Err(e) => { - tracing::error!("auth prompt task failed: {}", e); - AuthOutcome::Unavailable(UnavailableReason::NotInteractive) - } - } + authenticate_on_blocking_pool(reason).await } /// Check auth cache or prompt the user. Returns an [`AuthDecision`] @@ -1189,21 +1218,24 @@ impl VtSshSession { }; }; - // Check cache (read lock, released before auth prompt) - { - let cache = self.sign_auth_cache.read().await; - if cache.is_authorized(context, fingerprint) { - tracing::debug!( - "Sign auth cache hit for context={:?} fingerprint={}", - context, - fingerprint - ); - return AuthDecision::CacheHit; - } + // Fast path: cache hit without touching the prompt queue. + if self.sign_cache_hit(context, fingerprint, "").await { + return AuthDecision::CacheHit; } - // Prompt (no locks held) - let method = match self.authenticate_serialized(auth_message).await { + // Queue for the prompt, then RE-CHECK the cache: a racing request for + // the same key may have been approved (and granted — grants happen + // while the permit is still held) during the wait, so an N-request + // burst costs one dialog instead of N. + let Ok(_permit) = self.prompt_sem.acquire().await else { + return AuthDecision::Unavailable; + }; + if self.sign_cache_hit(context, fingerprint, " after prompt-queue wait").await { + return AuthDecision::CacheHit; + } + + // Prompt (permit held, no cache locks held) + let method = match authenticate_on_blocking_pool(auth_message).await { AuthOutcome::Success(m) => m, AuthOutcome::Rejected => return AuthDecision::Rejected, AuthOutcome::Unavailable(reason) => { @@ -1217,6 +1249,8 @@ impl VtSshSession { }; if method.is_cacheable() { + // Granted while the permit is still held, so queued waiters are + // guaranteed to observe this entry on their re-check. let mut cache = self.sign_auth_cache.write().await; cache.grant(context, fingerprint); tracing::debug!( @@ -1271,25 +1305,36 @@ impl VtSshSession { .map(|(t, salt)| decrypt_cache_key(*t, salt, host)) .collect(); - let miss_count = { - let now_mono = Instant::now(); - let now_wall = SystemTime::now(); - let cache = self.decrypt_auth_cache.read().await; - keys.iter() - .filter(|k| !cache.is_authorized_at(context, k, now_mono, now_wall)) - .count() - }; + // Fast path: full hit without touching the prompt queue. + if self.decrypt_miss_count(context, &keys).await == 0 { + tracing::debug!( + "Decrypt auth cache hit for context={:?} ({} v2 items)", + context, + v2_items.len() + ); + return DecryptDecision::CacheHit; + } + // Queue for the prompt, then RE-CHECK: a racing request covering the + // same records may have been approved (and granted — grants happen + // while the permit is still held) during the wait, so an N-request + // burst costs one dialog instead of N. + let Ok(_permit) = self.prompt_sem.acquire().await else { + return decrypt_decision_from_authenticate(AuthOutcome::Unavailable( + UnavailableReason::NotInteractive, + )); + }; + let miss_count = self.decrypt_miss_count(context, &keys).await; if miss_count == 0 { tracing::debug!( - "Decrypt auth cache hit for context={:?} ({} v2 items)", + "Decrypt auth cache hit after prompt-queue wait for context={:?} ({} v2 items)", context, v2_items.len() ); return DecryptDecision::CacheHit; } - let method = match self.authenticate_serialized(auth_message).await { + let method = match authenticate_on_blocking_pool(auth_message).await { AuthOutcome::Success(m) => m, AuthOutcome::Rejected => return DecryptDecision::Rejected, AuthOutcome::Unavailable(reason) => { @@ -1307,7 +1352,9 @@ impl VtSshSession { // valid at the check above but expired while the prompt sat on // screen would otherwise stay expired despite the fresh approval. // `grant_at` is strict-TTL: keys still valid at this instant keep - // their original expiry (no sliding refresh). + // their original expiry (no sliding refresh). Granted while the + // permit is still held, so queued waiters observe the entries on + // their re-check. let now_mono = Instant::now(); let now_wall = SystemTime::now(); let mut cache = self.decrypt_auth_cache.write().await; @@ -1325,6 +1372,32 @@ impl VtSshSession { DecryptDecision::Approved } + /// Sign-cache lookup with a hit-path debug log; `phase` distinguishes the + /// fast-path check from the post-queue re-check in the log line. + async fn sign_cache_hit(&self, context: CacheContext, fingerprint: &str, phase: &str) -> bool { + let hit = self.sign_auth_cache.read().await.is_authorized(context, fingerprint); + if hit { + tracing::debug!( + "Sign auth cache hit{} for context={:?} fingerprint={}", + phase, + context, + fingerprint + ); + } + hit + } + + /// Count how many of `keys` are NOT currently authorized for `context` — + /// 0 means a full cache hit. One clock capture per call. + async fn decrypt_miss_count(&self, context: CacheContext, keys: &[String]) -> usize { + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); + let cache = self.decrypt_auth_cache.read().await; + keys.iter() + .filter(|k| !cache.is_authorized_at(context, k, now_mono, now_wall)) + .count() + } + // ---- Structured-envelope dispatch helpers -------------------------------- // // Each `handle_*` returns either the inner JSON body (DEKs included for @@ -2729,6 +2802,10 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF AuthCacheMode::from_str("none").unwrap(), AuthCacheMode::None ); + assert_eq!( + AuthCacheMode::from_str("global").unwrap(), + AuthCacheMode::Global + ); assert_eq!( AuthCacheMode::from_str("per-session").unwrap(), AuthCacheMode::PerSession @@ -2801,6 +2878,23 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(cache.is_authorized(ctx, "fp1")); } + #[test] + fn test_resolve_cache_context_global_needs_no_tty() { + // Global must resolve for ANY identified peer — including TTY-less + // orchestrated callers (AI agents / CI) whose fresh-session spawns + // make per-session/per-app permanently miss. Our own test process + // works regardless of whether the runner has a controlling terminal. + let me = std::process::id() as i32; + assert_eq!( + resolve_cache_context(Some(me), AuthCacheMode::Global), + Some((0, 0)) + ); + // No peer PID → still uncacheable even in global mode. + assert_eq!(resolve_cache_context(None, AuthCacheMode::Global), None); + // None mode never caches. + assert_eq!(resolve_cache_context(Some(me), AuthCacheMode::None), None); + } + // (Strict-TTL idempotence and expired-entry replacement are covered // deterministically by the clock-injected `_at` tests below — no // sleep-based duplicates.) From f4be88715384d1144b1bad5fd6712c84dcbdd33a Mon Sep 17 00:00:00 2001 From: qiqi Date: Mon, 13 Jul 2026 18:46:19 +0800 Subject: [PATCH 3/4] feat(agent): sign@vt shares the sign auth cache (was always-prompt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vt ssh connect is also the transport for multi-host fan-outs (tssh h1 h2 ...), where v1's always-prompt policy produced one Touch ID per host. sign@vt now routes through check_or_prompt_auth, sharing the standard sign cache — same (context, fingerprint) key, same strict-TTL / dual-clock / lock-wake-idle-flush / prompt-queue-re-check semantics, same opt-in (--ssh-auth-cache-mode, default none = old behaviour). The v1 design's "cache-context escalation" objection (a standard-sign grant silently authorizing sign@vt for the same key, and vice versa) is accepted deliberately: both operations sign an arbitrary challenge with the same key, so a cached grant on either path already concedes the capability for the TTL; a per-(host, op) namespace would just re-add one prompt per host on fan-outs. docs/sign-vt-design.md §2/§5/§6/§9 updated to record the reversal. AuthDecision::Unavailable now carries UnavailableReason so sign@vt keeps its structured ErrKind mapping (SessionLocked/NoGuiSession) instead of flattening to Generic. --- CLAUDE.md | 2 +- docs/sign-vt-design.md | 49 +++++++++++++++++----------- src/server_macos/ssh_agent.rs | 61 ++++++++++++++++++++++------------- 3 files changed, 71 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a36be7..f664a76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ default to `none` (every request prompts): | flag | default TTL | scope | |---|---|---| -| `--ssh-auth-cache-mode` + `--ssh-auth-cache-duration` | 120s | SSH `sign`, per key fingerprint | +| `--ssh-auth-cache-mode` + `--ssh-auth-cache-duration` | 120s | SSH `sign` + `sign@vt` (`vt ssh connect`), per key fingerprint — the two share one cache | | `--decrypt-auth-cache-mode` + `--decrypt-auth-cache-duration` | 30s | `decrypt@vt`, per record (`SHA-256(type‖salt‖host)`); any legacy item in a batch disables caching for that batch | Modes: `per-session` keys on the terminal session leader (`getsid` + start diff --git a/docs/sign-vt-design.md b/docs/sign-vt-design.md index 148a10b..e0973ec 100644 --- a/docs/sign-vt-design.md +++ b/docs/sign-vt-design.md @@ -50,7 +50,9 @@ Keychain copy" idea does not apply here. agent's existing multi-algorithm signing core (Ed25519 / RSA-SHA2 / ECDSA P-256/P-384), so it is not pinned to Ed25519 even though the git identity from `vt ssh keygen` is Ed25519. -- No `sign@vt` Touch ID cache in v1 — it **always prompts** (see §6). +- `sign@vt` shares the sign auth cache with the standard `SIGN_REQUEST` path + (opt-in via `--ssh-auth-cache-mode`; default `none` = always prompts). v1 + shipped cache-free; see §6 for the revised decision. ## 3. Wire additions (`src/core.rs`, cross-platform) @@ -181,11 +183,11 @@ async fn handle_sign_vt( if !body.is_empty() { auth_message.push('\n'); auth_message.push_str(&body); } append_meta_lines(&mut auth_message, &req.meta); - // v1: ALWAYS PROMPT (no cache, see §6). Distinguish reject vs unavailable - // so the client gets the right ErrKind (AuthRejected => no fallback). - if let Some(kind) = outcome_to_err_strict(authenticate(&auth_message)) { - return Err((kind, auth_outcome_detail(kind))); - } + // Cache-aware (shared sign auth cache, see §6). Distinguish reject vs + // unavailable so the client gets the right ErrKind (AuthRejected => no + // fallback). (v1 always prompted here.) + let decision = self.check_or_prompt_auth(&fp_str, &auth_message).await; + /* … audit + map CacheHit/Approved → proceed, Rejected/Unavailable → ErrKind … */ let sig = sign_data_with_privkey(&privkey, &req.data, req.flags) .map_err(|_| (ErrKind::Generic, Some(DETAIL_SIGN_FAILED)))?; @@ -204,16 +206,26 @@ verification BEFORE dispatch, so `sign@vt` is VT_AUTH-gated for free, and the `handle_sign_vt` errors flow back through the same `ExtResponse` envelope as the other extensions. -## 6. AuthCache decision — v1 is NO cache - -`sign@vt` always prompts. git pushes are infrequent and a per-sign Touch ID is -cheap. Reusing the existing `sign_auth_cache` (keyed `(context, fingerprint)`) -would let a prior **standard**-sign grant silently authorize a richer-context -`sign@vt` for the same key (and vice versa) — a cache-context escalation. If a -cache is added later it MUST be a dedicated namespace keyed -`(context, fingerprint, host, op)`, never shared with the standard sign or -decrypt caches. Keeping v1 cache-free removes the only subtle correctness -question from the agent side. +## 6. AuthCache decision — shared with the standard sign cache (v1 was NO cache) + +v1 always prompted, on the theory that git pushes are infrequent and a +per-sign Touch ID is cheap. In practice `vt ssh connect` is also the transport +for multi-host fan-outs (`tssh h1 h2 …`), where a single command produced one +Touch ID **per host** — exactly the prompt storm the sign cache exists to +prevent. + +`sign@vt` therefore now goes through `check_or_prompt_auth`, sharing the +standard sign cache (keyed `(context, fingerprint)`; opt-in via +`--ssh-auth-cache-mode`, default `none` = v1 behaviour). The +originally-feared "cache-context escalation" (a standard-sign grant silently +authorizing `sign@vt` for the same key, and vice versa) is now accepted +deliberately: both operations are "sign an arbitrary challenge with this key", +so a cached grant on either path already concedes the same capability for the +TTL — a dedicated `(context, fingerprint, host, op)` namespace would re-add +one prompt per host on a fan-out (defeating the point) without denying a +cache holder any real power. The shared cache carries all the standard +protections: strict TTL, dual-clock expiry, lock/wake/idle flush, and the +prompt-queue re-check that collapses concurrent bursts to one dialog. ## 7. Client: `VTClient::sign_vt` (`src/client.rs`, `#[cfg(unix)]`) @@ -333,8 +345,9 @@ async fn sign(&mut self, request: SignRequest) -> Result "cache_hit", AuthDecision::Approved => "approved", AuthDecision::Rejected => "rejected", - AuthDecision::Unavailable => "unavailable", + AuthDecision::Unavailable(_) => "unavailable", } } } @@ -1200,9 +1202,10 @@ impl VtSshSession { /// distinguishing a silent cache hit from a fresh approval / denial so the /// `Session::sign` call site can audit the outcome. /// - /// Used for `sign` (SSH authentication). `auth@vt` always prompts because - /// forwarded agents share one local process. `decrypt@vt` has its own - /// cache (see `check_or_prompt_decrypt_batch`). + /// Used for `sign` (SSH authentication) and `sign@vt` (`vt ssh connect`), + /// which share one cache — both authorize "sign with this key". `auth@vt` + /// always prompts because forwarded agents share one local process. + /// `decrypt@vt` has its own cache (see `check_or_prompt_decrypt_batch`). /// /// All three success methods (Biometric, FIDO2, Password) are cached: /// FIDO2 (YubiKey touch) is treated as equivalent to Touch ID for @@ -1214,7 +1217,7 @@ impl VtSshSession { return match self.authenticate_serialized(auth_message).await { AuthOutcome::Success(_) => AuthDecision::Approved, AuthOutcome::Rejected => AuthDecision::Rejected, - AuthOutcome::Unavailable(_) => AuthDecision::Unavailable, + AuthOutcome::Unavailable(r) => AuthDecision::Unavailable(r), }; }; @@ -1228,7 +1231,7 @@ impl VtSshSession { // while the permit is still held) during the wait, so an N-request // burst costs one dialog instead of N. let Ok(_permit) = self.prompt_sem.acquire().await else { - return AuthDecision::Unavailable; + return AuthDecision::Unavailable(UnavailableReason::NotInteractive); }; if self.sign_cache_hit(context, fingerprint, " after prompt-queue wait").await { return AuthDecision::CacheHit; @@ -1244,7 +1247,7 @@ impl VtSshSession { fingerprint, reason ); - return AuthDecision::Unavailable; + return AuthDecision::Unavailable(reason); } }; @@ -1808,7 +1811,13 @@ impl VtSshSession { /// execution context (host/command/meta) in the Touch ID prompt. Unlike the /// standard `SIGN_REQUEST` path, the request is authenticated by the /// auth-cipher envelope and carries human context. The private key never - /// leaves the agent. v1 ALWAYS prompts (no cache — see docs/sign-vt-design.md). + /// leaves the agent. + /// + /// Shares the sign auth cache with the standard `SIGN_REQUEST` path (same + /// (context, fingerprint) key — both authorize "sign with this key"), so a + /// multi-host `vt ssh connect` fan-out costs one Touch ID within the TTL + /// instead of one per host. v1 always prompted; superseded by the cache + /// opt-in — mode `none` (default) restores per-request prompts. async fn handle_sign_vt( &self, decrypted: &[u8], @@ -1857,22 +1866,22 @@ impl VtSshSession { } append_meta_lines(&mut auth_message, &req.meta); - // v1: ALWAYS PROMPT (no cache). Distinguish reject vs unavailable so the - // client gets the right ErrKind (AuthRejected => no fallback, G3). + // Cache-aware authorization (same cache + double-check semantics as + // `Session::sign`). Distinguish reject vs unavailable so the client + // gets the right ErrKind (AuthRejected => no fallback, G3). let t0 = Instant::now(); - let outcome = self.authenticate_serialized(&auth_message).await; - let latency_ms = t0.elapsed().as_millis() as u64; + let decision = self.check_or_prompt_auth(&fp_str, &auth_message).await; + let latency_ms = if matches!(decision, AuthDecision::CacheHit) { + 0 + } else { + t0.elapsed().as_millis() as u64 + }; // Audit the decision (op_kind="ssh-sign", same as the CF ceremony sign // path) — without this, agent-side `vt ssh connect` signing left no // audit row, a visibility gap vs every other handler. - let outcome_str = match outcome { - AuthOutcome::Success(_) => "approved", - AuthOutcome::Rejected => "rejected", - AuthOutcome::Unavailable(_) => "unavailable", - }; self.emit_audit( "ssh-sign", - outcome_str, + decision.outcome(), &req.host, &req.meta, &req.command, @@ -1880,8 +1889,16 @@ impl VtSshSession { 0, latency_ms, ); - if let Some(kind) = outcome_to_err_strict(outcome) { - return Err((kind, auth_outcome_detail(kind))); + match decision { + AuthDecision::CacheHit | AuthDecision::Approved => {} + AuthDecision::Rejected => { + return Err((ErrKind::AuthRejected, Some(DETAIL_AUTH_REJECTED))); + } + AuthDecision::Unavailable(reason) => { + let kind = outcome_to_err_strict(AuthOutcome::Unavailable(reason)) + .unwrap_or(ErrKind::Generic); + return Err((kind, auth_outcome_detail(kind))); + } } let sig = sign_data_with_privkey(&privkey, &req.data, req.flags) From e8d6b37c7ad870b37367f7a7eadd081ac7ff3836 Mon Sep 17 00:00:00 2001 From: qiqi Date: Tue, 14 Jul 2026 10:19:34 +0800 Subject: [PATCH 4/4] feat: worker CI gate + inject crash-recovery + docs cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three high-leverage improvements from the project review, plus doc gaps. cf-worker CI gate (was: zero automated checks on half the system): - New CI job type-checks (tsc --noEmit) and unit-tests the worker on every PR — a TS error in the DO / WebAuthn / Access path can no longer reach `wrangler deploy`. - vitest with 18 tests over the runtime-free pure logic (b64u, challengeHash domain separation, ctEq, replay window, credentials parse/lookup). - justfile: `test`, `check-worker`, `ci` recipes. vt inject crash-recovery (was: reboot/SIGKILL left plaintext exposed indefinitely with no record): - A 0600 sidecar (~/.local/state/vt/inject/.json, paths + deadline, no secret) is written before any plaintext hits disk and deleted on every normal restore path (parent immediate-restore + supervisor post-timeout). - `vt inject --recover` (no auth — only moves the ciphertext backup back) sweeps the dir and restores entries whose backup survives and whose window has elapsed (+5s grace so a live supervisor is never raced). Pure `plan_recovery` decision is unit-tested; end-to-end verified. Docs/product-gap cleanup: - README: document run / hook / fido2 / inject --recover; add a Linux platform note to Quick Start (bootstrap is macOS-only). - CLAUDE.md: crash-recovery section replaces the old "known gap"; record the no-master-key-rotation limitation as accepted. - docs/structured-errors.md: flip status proposal -> shipped. --- .github/workflows/ci.yml | 27 + CLAUDE.md | 22 +- README.md | 14 + cf-worker/package-lock.json | 2239 +++++++++++++++++++++++----- cf-worker/package.json | 4 +- cf-worker/test/credentials.test.ts | 48 + cf-worker/test/crypto.test.ts | 98 ++ docs/structured-errors.md | 3 +- justfile | 16 + src/client.rs | 276 +++- src/main.rs | 13 + 11 files changed, 2362 insertions(+), 398 deletions(-) create mode 100644 cf-worker/test/credentials.test.ts create mode 100644 cf-worker/test/crypto.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a00d6a..03d390e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,33 @@ jobs: cargo check --locked cargo check --locked --target x86_64-unknown-linux-gnu + # The cf-worker is half the system (it verifies every ceremony) but ships via + # `wrangler deploy` with no compile gate of its own. Type-check it on every PR + # so a TS error in the DO / WebAuthn / Access path can't reach production. + worker: + name: cf-worker (typecheck + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: cf-worker/package-lock.json + + - name: Install deps + working-directory: cf-worker + run: npm ci + + - name: Type-check (tsc --noEmit) + working-directory: cf-worker + run: npx tsc --noEmit + + - name: Unit tests (vitest) + working-directory: cf-worker + run: npm test + # The shipping artifact is a musl-static binary (see justfile `build-musl`). # Build it in CI so musl-specific link regressions (the ring/CRT segfault the # justfile warns about) surface on PRs, not at release time. diff --git a/CLAUDE.md b/CLAUDE.md index f664a76..21f8a48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,7 +277,7 @@ Properties of the supervisor: - **Signal dispositions installed in the subcommand body, not `pre_exec`.** Rust's runtime resets signals between `execve` and our entry point; installing `SIG_IGN` for HUP/INT/TERM/PIPE/QUIT *after* runtime init and *before* the double-fork is the only way to make them survive into the grandchild. Verified via `/proc/$pid/status` `SigIgn` mask. - **Double-fork inside the subcommand body** orphans the sleeper to init (`PPID = 1`). The supervisor is invisible to user-cmd's process tree and `waitpid(-1)` loops. - **Stdio attached to `/dev/null`** so output never leaks back to the user's terminal. -- **SIGKILL is the only kill that lands.** SIGSTOP would pause but not terminate. Everything else is ignored. Reboot or `kill -9` is out of vt's reach; the file is left plaintext on disk with an orphaned `.vt-backup-*` next to it (no automatic crash-recovery yet — see "Known gaps" below). +- **SIGKILL is the only kill that lands.** SIGSTOP would pause but not terminate. Everything else is ignored. Reboot or `kill -9` is out of the supervisor's reach; the file is left plaintext on disk with an orphaned `.vt-backup-*` sibling — recoverable via the crash-recovery sidecar + `vt inject --recover` (see "Crash recovery" below). Other properties: @@ -287,10 +287,28 @@ Other properties: - **Symlinks refused.** `O_NOFOLLOW` on the original `-r` file and on every backup/temp create means a squatted symlink can't redirect the write. - **Path agnostic.** Same routing as the other CLI verbs — tries SSH agent first when `VT_AUTH` is set, falls back to the CF passkey ceremony. +**Crash recovery.** When the supervisor is armed, a sidecar JSON +(`~/.local/state/vt/inject/.json`, mode 0600, carrying only +`{target, backup, tmp, deadline_ms}` — no secret) is written *before any +plaintext hits disk*. It is deleted on every normal restore path (parent's +immediate-restore and the supervisor's post-timeout restore). If the machine +reboots or the supervisor is SIGKILLed mid-sleep, the sidecar survives: +`vt inject --recover` (run at login/boot — no `VT_AUTH` needed, it only moves +the ciphertext backup back) sweeps the dir and restores any entry whose backup +still exists and whose window has elapsed (past `deadline_ms + 5s` grace, so a +still-sleeping supervisor is never raced). The pure decision is +`plan_recovery` in `src/client.rs` (unit-tested); a not-yet-elapsed entry is +reported as "still active" and left for its supervisor. + Known gaps: -- **No crash-recovery on reboot.** If the system reboots while the supervisor is sleeping, the supervisor dies and never restores. Plaintext stays at `target`, ciphertext sits at the orphaned `.{name}.vt-backup-*` sibling. Manual recovery is `mv .{name}.vt-backup-* target`. A future on-boot sweep (sidecar state files + `vt inject --recover`) is deferred. +- **Reboot inside a long window.** `--recover` restores only past-deadline + entries, so plaintext exposed under a long `--timeout` stays exposed until + the window elapses; re-running `--recover` after that restores it. (Strictly + better than the previous "no recovery at all" — the sidecar guarantees the + exposure is discoverable.) - **Snapshot / backup leakage.** If Time Machine, ZFS snapshots, restic, etc., run while the plaintext is exposed, the snapshot retains plaintext indefinitely even after the supervisor restores. Out of scope. +- **No master-key rotation.** `master_key == mac_key` is a deliberate unified-vault invariant, so revoking a Passkey or rotating `CACHE_SECKEY` does not re-key stored records: a leaked master leaves every past/future `vt://` decryptable. Accepted for now; a break-glass `vt rotate-master` (re-wrap under all Passkeys + re-encrypt every record) is not implemented. ## vt hook — transparent secrets for AI coding agents diff --git a/README.md b/README.md index 4a6e2ac..7ab414f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ just install ## Quick Start +> **Platform note.** Vault bootstrap and key storage (`init`, `secret *`, +> `fido2 *`, `ssh agent`/`add`/`list`/`remove`/`comment`/`show`) are +> macOS-only — they use the Keychain + Touch ID. A Linux host has no local +> vault: it decrypts by pointing `VT_PASSKEY_URL` + `VT_PASSKEY_TOKEN` at the +> Cloudflare Worker and approving each ceremony on a phone (see the passkey +> deployment docs). Steps 1–2 below assume macOS. + 1. Initialize the vault (creates the `rusty.vault.store` keychain item): ```bash vt init @@ -60,7 +67,11 @@ just install | `read ` | Decrypt a vt protocol string | | `rewrap [--no-dry-run] [--backup] ...` | Re-encrypt legacy `vt://mac/...` URLs in files to the current envelope format (one Touch ID per batch) | | `inject [-r FILE] -- cmd...` | Transiently decrypt `vt://` in the file / env / argv, then exec the command | +| `inject --recover` | Restore ciphertext for any file left decrypted by a crashed/rebooted supervisor (run at login/boot; no auth) | | `auth [--reason ]` | Trigger bio auth via SSH agent forwarding (for PAM/sudo) | +| `run -- argv...` | (SSH-agent path) Ask a forwarded macOS agent to launch an allowlisted program locally after Touch ID | +| `hook {claude,check,exec,install-shims}` | AI-agent command hook: decide/rewrite a proposed command per `~/.config/vt/agent.toml` so `vt://` env secrets decrypt on demand (see below) | +| `fido2 {register,list,remove,remove-all}` | (macOS) Manage FIDO2/YubiKey credentials used as a Touch-ID fallback factor | | `secret export` | (macOS) Export the encrypted master secret | | `secret import` | (macOS) Import an encrypted master secret | | `secret rotate-passcode` | (macOS) Rotate the passcode for the master secret | @@ -98,6 +109,9 @@ vt inject -- ./run.sh Options: - `-r, --replace-file `: Decrypt vt:// in the file in place; restore from backup after timeout - `-t, --timeout `: Seconds before the backup is rolled back over the decrypted original (default: 2) +- `--recover`: Sweep `~/.local/state/vt/inject/` and restore any file a crashed + or rebooted restore supervisor left decrypted. Needs no auth (it only moves + the ciphertext backup back). Safe to run from a login/boot hook. ### SSH Agent diff --git a/cf-worker/package-lock.json b/cf-worker/package-lock.json index 783d677..1a04692 100644 --- a/cf-worker/package-lock.json +++ b/cf-worker/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20250124.0", "typescript": "^5.7.3", + "vitest": "^2.1.9", "wrangler": "^3.101.0" } }, @@ -187,6 +188,23 @@ "esbuild": "*" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.17.19", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", @@ -1015,550 +1033,1997 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=0.4.0" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "printable-characters": "^1.0.42" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/blakejs": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", - "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } + "os": [ + "freebsd" + ] }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } + "os": [ + "freebsd" + ] }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "optional": true + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } + "os": [ + "linux" + ] }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "Apache-2.0", + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, - "engines": { - "node": ">=8" - } + "os": [ + "linux" + ] }, - "node_modules/esbuild": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", - "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], "dev": true, - "hasInstallScript": true, + "libc": [ + "glibc" + ], "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.17.19", - "@esbuild/android-arm64": "0.17.19", - "@esbuild/android-x64": "0.17.19", - "@esbuild/darwin-arm64": "0.17.19", - "@esbuild/darwin-x64": "0.17.19", - "@esbuild/freebsd-arm64": "0.17.19", - "@esbuild/freebsd-x64": "0.17.19", - "@esbuild/linux-arm": "0.17.19", - "@esbuild/linux-arm64": "0.17.19", - "@esbuild/linux-ia32": "0.17.19", - "@esbuild/linux-loong64": "0.17.19", - "@esbuild/linux-mips64el": "0.17.19", - "@esbuild/linux-ppc64": "0.17.19", - "@esbuild/linux-riscv64": "0.17.19", - "@esbuild/linux-s390x": "0.17.19", - "@esbuild/linux-x64": "0.17.19", - "@esbuild/netbsd-x64": "0.17.19", - "@esbuild/openbsd-x64": "0.17.19", - "@esbuild/sunos-x64": "0.17.19", - "@esbuild/win32-arm64": "0.17.19", - "@esbuild/win32-ia32": "0.17.19", - "@esbuild/win32-x64": "0.17.19" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/estree-walker": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/exit-hook": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", - "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], "dev": true, - "hasInstallScript": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Unlicense", - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - } + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause" + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/hono": { - "version": "4.12.21", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", - "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=16.9.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "optional": true + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "bin": { - "mime": "cli.js" + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" }, - "engines": { - "node": ">=10.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/miniflare": { - "version": "3.20250718.3", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", - "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", "dev": true, "license": "MIT", "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "acorn": "8.14.0", - "acorn-walk": "8.3.2", - "exit-hook": "2.2.1", - "glob-to-regexp": "0.4.1", - "stoppable": "1.1.0", - "undici": "^5.28.5", - "workerd": "1.20250718.0", - "ws": "8.18.0", - "youch": "3.3.4", - "zod": "3.22.3" + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" }, - "bin": { - "miniflare": "bootstrap.js" + "funding": { + "url": "https://opencollective.com/vitest" }, - "engines": { - "node": ">=16.13" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", - "bin": { - "mustache": "bin/mustache" + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true, - "license": "Unlicense" + "license": "MIT" }, - "node_modules/rollup-plugin-inject": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", - "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", "dev": true, "license": "MIT", "dependencies": { - "estree-walker": "^0.6.1", - "magic-string": "^0.25.3", - "rollup-pluginutils": "^2.8.1" + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/rollup-plugin-node-polyfills": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", - "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "rollup-plugin-inject": "^3.0.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/rollup-pluginutils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", - "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", "dev": true, "license": "MIT", "dependencies": { - "estree-walker": "^0.6.1" + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", "dev": true, - "license": "ISC", - "optional": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", "bin": { - "semver": "bin/semver.js" + "acorn": "bin/acorn" }, "engines": { - "node": ">=10" + "node": ">=0.4.0" } }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "node": ">=0.4.0" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "is-arrayish": "^0.3.1" + "printable-characters": "^1.0.42" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", "dev": true, "license": "MIT" }, - "node_modules/stacktracey": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", - "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "node_modules/blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, - "license": "Unlicense", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { - "node": ">=4", - "npm": ">=6" + "node": ">= 16" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "dev": true, - "license": "0BSD", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", "optional": true }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "license": "Unlicense" + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", "engines": { - "node": ">=14.17" + "node": ">= 0.6" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "@fastify/busboy": "^2.0.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=14.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/unenv": { - "version": "2.0.0-rc.14", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", - "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "dependencies": { - "defu": "^6.1.4", - "exsolve": "^1.0.1", - "ohash": "^2.0.10", - "pathe": "^2.0.3", - "ufo": "^1.5.4" + "engines": { + "node": ">=6" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/hono": { + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" } }, "node_modules/workerd": { diff --git a/cf-worker/package.json b/cf-worker/package.json index 70dcf32..76e756b 100644 --- a/cf-worker/package.json +++ b/cf-worker/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "hono": "^4.7.5", @@ -15,6 +16,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20250124.0", "typescript": "^5.7.3", + "vitest": "^2.1.9", "wrangler": "^3.101.0" } } diff --git a/cf-worker/test/credentials.test.ts b/cf-worker/test/credentials.test.ts new file mode 100644 index 0000000..84dd285 --- /dev/null +++ b/cf-worker/test/credentials.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { parseCredentials, lookupByCredentialId } from '../src/credentials'; +import { b64uEnc, sha256 } from '../src/crypto'; + +const entry = (over: Record = {}) => ({ + h: 'aGFzaA', + i: 'aWQ', + k: 'a2V5', + p: 'cHVi', + l: 'label', + t: 1716105600, + ...over, +}); + +describe('parseCredentials', () => { + it('accepts a well-formed v1 blob', () => { + const blob = parseCredentials(JSON.stringify({ v: 1, epoch: 3, c: [entry()] })); + expect(blob.c).toHaveLength(1); + expect(blob.epoch).toBe(3); + }); + + it('rejects an unsupported version', () => { + expect(() => parseCredentials(JSON.stringify({ v: 2, c: [] }))).toThrow(/version/); + }); + + it('rejects a non-array `c`', () => { + expect(() => parseCredentials(JSON.stringify({ v: 1, c: {} }))).toThrow(/`c` must be an array/); + }); + + it('rejects an entry missing a required string field', () => { + expect(() => parseCredentials(JSON.stringify({ v: 1, c: [entry({ k: undefined })] }))) + .toThrow(/missing required string field/); + }); +}); + +describe('lookupByCredentialId', () => { + it('finds the entry whose h == b64u(sha256(credId))', async () => { + const credId = new Uint8Array([9, 8, 7, 6, 5]); + const h = b64uEnc(await sha256(credId)); + const blob = parseCredentials(JSON.stringify({ v: 1, c: [entry({ h })] })); + expect(await lookupByCredentialId(blob, credId)).toBeDefined(); + }); + + it('returns undefined for an unknown credId', async () => { + const blob = parseCredentials(JSON.stringify({ v: 1, c: [entry()] })); + expect(await lookupByCredentialId(blob, new Uint8Array([1, 2, 3]))).toBeUndefined(); + }); +}); diff --git a/cf-worker/test/crypto.test.ts b/cf-worker/test/crypto.test.ts new file mode 100644 index 0000000..ff2b337 --- /dev/null +++ b/cf-worker/test/crypto.test.ts @@ -0,0 +1,98 @@ +// Unit tests for the runtime-free crypto primitives. These run under plain +// vitest (Node's global WebCrypto + btoa/atob), no workerd needed — the point +// is a fast gate on the load-bearing pure logic (b64u, challenge_hash domain +// separation, constant-time compare, replay window). + +import { describe, it, expect } from 'vitest'; +import { + b64uEnc, + b64uDec, + decodeB64uExact, + isB64uString, + ctEq, + inReplayWindow, + challengeHash, +} from '../src/crypto'; + +describe('b64u', () => { + it('round-trips arbitrary bytes', () => { + for (const len of [0, 1, 2, 3, 16, 32, 100]) { + const bytes = new Uint8Array(len).map((_, i) => (i * 37 + 11) & 0xff); + expect(Array.from(b64uDec(b64uEnc(bytes)))).toEqual(Array.from(bytes)); + } + }); + + it('emits url-safe alphabet with no padding', () => { + const s = b64uEnc(new Uint8Array([0xff, 0xef, 0xfe])); + expect(s).not.toMatch(/[+/=]/); + }); + + it('rejects a length ≡ 1 (mod 4) that decodes to no byte sequence', () => { + expect(() => b64uDec('A')).toThrow(/length/); + }); +}); + +describe('decodeB64uExact', () => { + it('accepts the exact length', () => { + expect(decodeB64uExact(b64uEnc(new Uint8Array(32)), 32, 'x').length).toBe(32); + }); + it('rejects the wrong length', () => { + expect(() => decodeB64uExact(b64uEnc(new Uint8Array(16)), 32, 'x')).toThrow(/wrong length/); + }); + it('rejects non-string / non-b64u input', () => { + expect(() => decodeB64uExact(123, 32, 'x')).toThrow(/not b64u/); + expect(() => decodeB64uExact('has spaces!', 32, 'x')).toThrow(/not b64u/); + }); + it('isB64uString guards', () => { + expect(isB64uString('AbC-_9')).toBe(true); + expect(isB64uString('')).toBe(false); + expect(isB64uString('a=b')).toBe(false); + expect(isB64uString(42)).toBe(false); + }); +}); + +describe('ctEq', () => { + it('true iff equal, and length-mismatch is false', () => { + expect(ctEq(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true); + expect(ctEq(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false); + expect(ctEq(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false); + }); +}); + +describe('inReplayWindow', () => { + it('accepts within ±5min, rejects outside', () => { + const now = 1_700_000_000_000; + expect(inReplayWindow(now, now)).toBe(true); + expect(inReplayWindow(now, now - 5 * 60 * 1000)).toBe(true); + expect(inReplayWindow(now, now + 5 * 60 * 1000)).toBe(true); + expect(inReplayWindow(now, now - 5 * 60 * 1000 - 1)).toBe(false); + expect(inReplayWindow(now, now + 5 * 60 * 1000 + 1)).toBe(false); + }); +}); + +describe('challengeHash', () => { + const pk = new Uint8Array(32).fill(1); + const nonce = new Uint8Array(16).fill(2); + const salts = [new Uint8Array(16).fill(3), new Uint8Array(16).fill(4)]; + + it('is deterministic for identical inputs', async () => { + const a = await challengeHash(pk, nonce, 12345, salts, 'approve'); + const b = await challengeHash(pk, nonce, 12345, salts, 'approve'); + expect(ctEq(a, b)).toBe(true); + expect(a.length).toBe(32); + }); + + it('domain-separates approve from reject (replay guard)', async () => { + const approve = await challengeHash(pk, nonce, 12345, salts, 'approve'); + const reject = await challengeHash(pk, nonce, 12345, salts, 'reject'); + expect(ctEq(approve, reject)).toBe(false); + }); + + it('changes when any bound field changes', async () => { + const base = await challengeHash(pk, nonce, 12345, salts, 'approve'); + const diffTs = await challengeHash(pk, nonce, 12346, salts, 'approve'); + const diffSalt = await challengeHash(pk, nonce, 12345, [salts[0]!, new Uint8Array(16).fill(9)], 'approve'); + expect(ctEq(base, diffTs)).toBe(false); + expect(ctEq(base, diffSalt)).toBe(false); + }); +}); diff --git a/docs/structured-errors.md b/docs/structured-errors.md index db66369..26fa404 100644 --- a/docs/structured-errors.md +++ b/docs/structured-errors.md @@ -1,6 +1,7 @@ # Structured Errors over the vt Extension Protocol -Status: proposal (pending codex-expert review) +Status: shipped — `ExtResponse`/`ErrKind` in `src/core/wire.rs`, `VtClientError` +downcast + exit-code mapping in `src/client.rs` (with tests). ## Motivation diff --git a/justfile b/justfile index 1e2fe1d..fca2bc9 100644 --- a/justfile +++ b/justfile @@ -45,6 +45,22 @@ check: cargo check cargo check --target x86_64-unknown-linux-gnu +# Run the Rust unit + integration tests +test: + cargo test --all-targets + +# Type-check + unit-test the Cloudflare worker. Installs deps on first run. +[working-directory: 'cf-worker'] +check-worker: + #!/usr/bin/env bash + set -euo pipefail + [ -d node_modules ] || npm ci + npx tsc --noEmit + npm test + +# Everything the CI gates run, in one shot (Rust + worker) +ci: check test check-worker + # Deploy the Cloudflare worker (requires wrangler on PATH) [working-directory: 'cf-worker'] deploy-worker: diff --git a/src/client.rs b/src/client.rs index 0107035..cb421d2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -17,7 +17,7 @@ use crate::core::{ SecretType, SignReq, SignRes, VtUrl, SALT_LEN, }; use anyhow::{ensure, Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use ssh_agent_lib::proto::{Extension, Unparsed}; use tracing::debug; @@ -1299,7 +1299,8 @@ pub async fn inject( // durable backstop: even if a parent crash makes immediate_restore // unreachable, the supervisor's `unlink(tmp) + rename(backup, target)` // after timeout still brings everything home (modulo SIGKILL / reboot). - let armed: Option<(String, std::path::PathBuf)> = if let Some(replace_file_path) = &replace_file + let armed: Option<(String, std::path::PathBuf, std::path::PathBuf)> = + if let Some(replace_file_path) = &replace_file { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; @@ -1323,6 +1324,16 @@ pub async fn inject( let suffix: String = rnd.iter().map(|b| format!("{:02x}", b)).collect(); let backup_path = dir.join(format!(".{}.vt-backup-{}", file_name, suffix)); let tmp_path = dir.join(format!(".{}.vt-tmp-{}", file_name, suffix)); + // Crash-recovery sidecar path in the state dir. Computed up front so + // the supervisor gets it at spawn and can delete it after a normal + // restore. If the home dir can't be resolved we fall back to a path + // beside the target: the supervisor can still delete it on the normal + // path, but `vt inject --recover` (which only scans the state dir) will + // not find it — crash-recovery is effectively unavailable without a + // home dir. This mirrors `inject_recover`, which also bails on `None`. + let sidecar_path = inject_state_dir() + .map(|d| d.join(format!("{suffix}.json"))) + .unwrap_or_else(|| dir.join(format!(".{}.vt-recover-{}.json", file_name, suffix))); // Step 2: write backup from the in-memory ORIGINAL bytes (captured at // the single O_NOFOLLOW open above) — NOT a fresh re-read of the target, @@ -1348,7 +1359,13 @@ pub async fn inject( debug!("Created backup at: {}", backup_path.display()); // Step 3: arm supervisor before any plaintext touches disk. - match spawn_restore_supervisor(timeout, &tmp_path, &backup_path, replace_file_path) { + match spawn_restore_supervisor( + timeout, + &tmp_path, + &backup_path, + replace_file_path, + &sidecar_path, + ) { Ok(()) => debug!("Restore supervisor armed (timeout={}s)", timeout), Err(e) => { let _ = std::fs::remove_file(&backup_path); @@ -1356,12 +1373,28 @@ pub async fn inject( } } + // Step 3b: write the crash-recovery sidecar (before any plaintext hits + // disk). Best-effort — losing it only forfeits reboot recovery for this + // one injection, so a failure warns rather than aborts. + let sidecar = InjectSidecar { + target: absolutize(std::path::Path::new(replace_file_path)) + .to_string_lossy() + .into_owned(), + backup: absolutize(&backup_path).to_string_lossy().into_owned(), + tmp: absolutize(&tmp_path).to_string_lossy().into_owned(), + deadline_ms: now_ms().saturating_add((timeout as u64).saturating_mul(1000)), + }; + if let Err(e) = write_inject_sidecar(&sidecar_path, &sidecar) { + debug!("inject sidecar not written ({e:#}); crash-recovery disabled for this run"); + } + // Step 4: write tmp (plaintext). if let Err(e) = write_plaintext_tmp(&tmp_path, orig_mode, &decrypted_file_content) { // Parent does immediate cleanup so the failure leaves no visible // sidecar state for the timeout window. Supervisor will later // observe ENOENT on backup and exit silently. let _ = std::fs::remove_file(&tmp_path); + let _ = std::fs::remove_file(&sidecar_path); immediate_restore(replace_file_path, &backup_path); return Err(e); } @@ -1369,6 +1402,7 @@ pub async fn inject( // Step 5: atomically expose plaintext at target. if let Err(e) = std::fs::rename(&tmp_path, replace_file_path) { let _ = std::fs::remove_file(&tmp_path); + let _ = std::fs::remove_file(&sidecar_path); immediate_restore(replace_file_path, &backup_path); return Err(e).with_context(|| { format!("Failed to atomically replace file: {}", replace_file_path) @@ -1376,7 +1410,7 @@ pub async fn inject( } debug!("Content written to replace file: {}", replace_file_path); - Some((replace_file_path.clone(), backup_path)) + Some((replace_file_path.clone(), backup_path, sidecar_path)) } else { None }; @@ -1398,8 +1432,9 @@ pub async fn inject( // Restore immediately on exec failure so the user doesn't wait out the // supervisor's timeout. The supervisor will later observe ENOENT on the // backup and exit silently. - if let Some((target, backup)) = &armed { + if let Some((target, backup, sidecar)) = &armed { immediate_restore(target, backup); + let _ = std::fs::remove_file(sidecar); } Err(anyhow::anyhow!("Failed to execute command: {}", err)) } @@ -1435,6 +1470,172 @@ fn write_plaintext_tmp(tmp: &std::path::Path, mode: u32, content: &str) -> Resul Ok(()) } +// ── Crash-recovery sidecar ────────────────────────────────────────────────── +// +// The restore supervisor holds its state (tmp/backup/target/deadline) only in +// its own argv. A reboot or SIGKILL kills the sleeper and leaves the plaintext +// exposed at `target` with an orphaned ciphertext backup beside it — with no +// record of what to restore. The sidecar closes that gap: a tiny JSON file +// written (before any plaintext hits disk) at a stable, discoverable location, +// so `vt inject --recover` can find and undo orphaned exposures after a crash. +// It carries no secret — only paths + a deadline. + +/// One armed injection's recovery record. Absolute paths so `--recover` works +/// from any cwd. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct InjectSidecar { + /// The file being exposed as plaintext (ciphertext must be restored here). + target: String, + /// The ciphertext backup to rename back over `target`. + backup: String, + /// The plaintext tmp file, removed on recovery if it was orphaned. + tmp: String, + /// Epoch ms after which the supervisor should already have restored; past + /// this (plus a grace) a surviving backup means the supervisor died. + deadline_ms: u64, +} + +/// Wait past the deadline by this much before `--recover` acts, so a +/// legitimately-still-sleeping supervisor is never raced. +const RECOVER_GRACE_MS: u64 = 5_000; + +/// What `--recover` should do with one sidecar entry. +#[derive(Debug, PartialEq, Eq)] +enum RecoverAction { + /// Backup present and the window has elapsed → supervisor is dead; restore. + Restore, + /// Backup already consumed (normal completion) → sidecar is stale; delete. + CleanStale, +} + +/// Pure recovery decision. `None` = leave it alone (an injection whose window +/// has not yet elapsed — its supervisor is presumed still running). +fn plan_recovery(deadline_ms: u64, backup_exists: bool, now_ms: u64) -> Option { + if !backup_exists { + // The supervisor (or immediate_restore) already renamed the backup + // over the target: the injection completed. Only the sidecar lingers. + return Some(RecoverAction::CleanStale); + } + if now_ms >= deadline_ms.saturating_add(RECOVER_GRACE_MS) { + Some(RecoverAction::Restore) + } else { + None + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// `~/.local/state/vt/inject` — the sidecar directory (same on macOS and Linux +/// for a single recovery path). `None` if the home dir can't be resolved. +fn inject_state_dir() -> Option { + dirs::home_dir().map(|h| h.join(".local").join("state").join("vt").join("inject")) +} + +/// Make `p` absolute (without requiring it to exist — the target is about to +/// be renamed over) so the recorded path resolves from any later cwd. +fn absolutize(p: &std::path::Path) -> std::path::PathBuf { + if p.is_absolute() { + p.to_path_buf() + } else { + std::env::current_dir() + .map(|d| d.join(p)) + .unwrap_or_else(|_| p.to_path_buf()) + } +} + +/// Write the sidecar at `path` (mode 0600), creating its parent dir. Best +/// effort at the call site: a failure here loses only crash-recovery for this +/// one injection (the in-memory supervisor still restores on the normal path), +/// so callers warn and continue rather than aborting the user's command. +fn write_inject_sidecar(path: &std::path::Path, sc: &InjectSidecar) -> Result<()> { + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; + if let Some(dir) = path.parent() { + // Create every missing component as 0700 in one step (no create-then- + // chmod window) — the dir lists which files are mid-exposure. + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("creating inject state dir {}", dir.display()))?; + } + let json = serde_json::to_vec(sc).context("serialize inject sidecar")?; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("creating inject sidecar {}", path.display()))?; + f.write_all(&json) + .with_context(|| format!("writing inject sidecar {}", path.display()))?; + f.sync_all().ok(); + Ok(()) +} + +/// Sweep the sidecar dir and restore any orphaned plaintext exposure. Invoked +/// by `vt inject --recover` (run at login/boot). Never errors on an individual +/// bad entry — it logs and moves on so one corrupt sidecar can't wedge the sweep. +pub fn inject_recover() -> Result<()> { + let Some(dir) = inject_state_dir() else { + eprintln!("vt inject --recover: cannot resolve home dir"); + return Ok(()); + }; + if !dir.exists() { + println!("vt inject --recover: nothing to recover"); + return Ok(()); + } + let now = now_ms(); + let (mut restored, mut cleaned, mut active) = (0u32, 0u32, 0u32); + let entries = std::fs::read_dir(&dir) + .with_context(|| format!("reading inject state dir {}", dir.display()))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let sc: InjectSidecar = match std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(sc) => sc, + None => { + eprintln!("vt inject --recover: skipping unreadable sidecar {}", path.display()); + continue; + } + }; + let backup_exists = std::path::Path::new(&sc.backup).exists(); + match plan_recovery(sc.deadline_ms, backup_exists, now) { + Some(RecoverAction::Restore) => { + let _ = std::fs::remove_file(&sc.tmp); + if let Err(e) = std::fs::rename(&sc.backup, &sc.target) { + eprintln!( + "vt inject --recover: failed to restore {} from {}: {}", + sc.target, sc.backup, e + ); + continue; // leave the sidecar so a later sweep retries + } + let _ = std::fs::remove_file(&path); + println!("vt inject --recover: restored {}", sc.target); + restored += 1; + } + Some(RecoverAction::CleanStale) => { + let _ = std::fs::remove_file(&sc.tmp); + let _ = std::fs::remove_file(&path); + cleaned += 1; + } + None => active += 1, + } + } + println!( + "vt inject --recover: {restored} restored, {cleaned} stale cleaned, {active} still active" + ); + Ok(()) +} + /// Spawn the restore supervisor as a self-exec'd child. The intermediate /// process exits immediately after double-forking inside the supervisor /// subcommand body; we reap that exit here, leaving the grandchild reparented @@ -1444,6 +1645,7 @@ fn spawn_restore_supervisor( tmp_path: &std::path::Path, backup_path: &std::path::Path, target_path: &str, + sidecar_path: &std::path::Path, ) -> Result<()> { use std::os::unix::process::CommandExt; use std::process::{Command, Stdio}; @@ -1456,6 +1658,7 @@ fn spawn_restore_supervisor( .arg(tmp_path) .arg(backup_path) .arg(target_path) + .arg(sidecar_path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -1497,12 +1700,13 @@ fn spawn_restore_supervisor( /// parses clap definitions, or loads tracing — its RSS is just vt's text /// segment (shared with the parent via page cache) + a few KB of heap. /// -/// Args (after the SUPERVISOR_SUBCOMMAND marker): ` `. +/// Args (after the SUPERVISOR_SUBCOMMAND marker): +/// ` `. pub fn supervisor_main(args: &[std::ffi::OsString]) -> i32 { // Stdio is /dev/null — any failure here is invisible to the user. Parent // distinguishes the intermediate's success (exit 0 after double-fork) // from any failure (non-zero) via Child::wait(). - if args.len() != 4 { + if args.len() != 5 { return 2; } let secs: u64 = match args[0].to_str().and_then(|s| s.parse().ok()) { @@ -1512,6 +1716,7 @@ pub fn supervisor_main(args: &[std::ffi::OsString]) -> i32 { let tmp = std::path::PathBuf::from(&args[1]); let backup = std::path::PathBuf::from(&args[2]); let target = std::path::PathBuf::from(&args[3]); + let sidecar = std::path::PathBuf::from(&args[4]); // Install SIG_IGN for the signals that would otherwise sweep us up when // the user closes the terminal, hits Ctrl+C, or runs `pkill vt`. These @@ -1566,6 +1771,9 @@ pub fn supervisor_main(args: &[std::ffi::OsString]) -> i32 { // Restore ciphertext over the target. Either succeeds (normal case) or // returns ENOENT (parent already restored on exec failure / rename failure). let _ = std::fs::rename(&backup, &target); + // The exposure is over; drop the crash-recovery sidecar so `--recover` + // doesn't later see a stale entry. + let _ = std::fs::remove_file(&sidecar); 0 } @@ -1755,4 +1963,58 @@ mod tests { let e2 = VtClientError::Agent(ErrKind::SessionLocked, None); assert_eq!(e2.to_string(), "vt: screen is locked"); } + + // ── inject crash-recovery ─────────────────────────────────────────────── + + #[test] + fn plan_recovery_cleans_stale_when_backup_gone() { + // Backup already consumed (supervisor/immediate_restore renamed it) → + // the injection completed; only the sidecar lingers. Deadline is + // irrelevant on this arm. + assert_eq!( + plan_recovery(1_000, false, 500), + Some(RecoverAction::CleanStale) + ); + assert_eq!( + plan_recovery(1_000, false, 999_999), + Some(RecoverAction::CleanStale) + ); + } + + #[test] + fn plan_recovery_restores_only_past_deadline_plus_grace() { + let deadline = 1_000_000; + // Before the deadline: an active injection — leave it for its supervisor. + assert_eq!(plan_recovery(deadline, true, deadline - 1), None); + // At the deadline but within the grace window: still hands-off, so a + // supervisor firing at exactly the deadline isn't raced. + assert_eq!(plan_recovery(deadline, true, deadline), None); + assert_eq!(plan_recovery(deadline, true, deadline + RECOVER_GRACE_MS - 1), None); + // Past deadline + grace with a surviving backup → supervisor is dead. + assert_eq!( + plan_recovery(deadline, true, deadline + RECOVER_GRACE_MS), + Some(RecoverAction::Restore) + ); + } + + #[test] + fn inject_sidecar_json_roundtrips() { + let sc = InjectSidecar { + target: "/abs/secret.env".into(), + backup: "/abs/.secret.env.vt-backup-ab".into(), + tmp: "/abs/.secret.env.vt-tmp-ab".into(), + deadline_ms: 1_723_000_000_000, + }; + let bytes = serde_json::to_vec(&sc).unwrap(); + let back: InjectSidecar = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(sc, back); + } + + #[test] + fn absolutize_leaves_absolute_paths_untouched() { + let p = std::path::Path::new("/already/absolute"); + assert_eq!(absolutize(p), p); + // A relative path becomes absolute (prefixed by some cwd). + assert!(absolutize(std::path::Path::new("rel/x")).is_absolute()); + } } diff --git a/src/main.rs b/src/main.rs index db095c9..0ab04d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,13 @@ enum Commands { )] only_env: Option>, + #[arg( + long, + conflicts_with_all = ["replace_file", "reason", "only_env", "args"], + help = "Restore ciphertext for any file left decrypted by a crashed or rebooted restore supervisor (sweeps the injection state dir). Run at login/boot. Needs no auth — it only moves the ciphertext backup back over the target." + )] + recover: bool, + #[arg( trailing_var_arg = true, help = "Additional arguments to pass to the spawned process" @@ -527,8 +534,14 @@ async fn run(cli: Cli) -> Result<()> { timeout, reason, only_env, + recover, args, } => { + // Recovery only moves the ciphertext backup back over the target — + // no decryption, so no VT_AUTH required. + if *recover { + return client::inject_recover(); + } let auth = require_auth(&cli.auth)?; let vt_client = VTClient::new(auth)?; client::inject(