From 9b5c1ac6b3fa83ad04d387cc03fb1c7d3df6e026 Mon Sep 17 00:00:00 2001 From: GrillerGeek Date: Thu, 23 Jul 2026 22:14:13 -0400 Subject: [PATCH 1/3] Add design spec for opt-in Sentry crash reporting Co-Authored-By: Claude Fable 5 --- .../2026-07-23-crash-reporting-design.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-crash-reporting-design.md diff --git a/docs/superpowers/specs/2026-07-23-crash-reporting-design.md b/docs/superpowers/specs/2026-07-23-crash-reporting-design.md new file mode 100644 index 0000000..28e160b --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-crash-reporting-design.md @@ -0,0 +1,139 @@ +# Opt-in Crash Reporting via Sentry — Design + +**Date:** 2026-07-23 +**Status:** Approved design, pending implementation plan + +## Goal + +Now that AgentPanel has external users, we need to know when the app crashes or errors +in the wild — automatically, without relying on users to file issues. Scope is crash and +error reporting only: no usage analytics, no feature tracking. + +## Decisions (agreed in brainstorming) + +- **What:** Automatic capture of Rust panics and frontend (WebView) exceptions. +- **Consent:** Strictly opt-in via a first-run prompt. No data leaves the machine until + the user says yes. +- **Backend:** Hosted Sentry (sentry.io free tier). The DSN is embedded in the source — + DSNs are write-only public keys, safe in an OSS repo. + +## Architecture + +### Consent storage (Rust-owned, not localStorage) + +Frontend settings currently persist to `localStorage` (`agentpanel.settings` in +`src/state/store.ts`), which Rust cannot read before the WebView exists. Crash reporting +must be decided at process start (the panic hook has to be installed before anything can +panic), so consent lives in a small Rust-owned JSON file in the app config directory +(sibling of the existing `store.rs` data), e.g. `telemetry.json`: + +```json +{ "consent": "unset" | "granted" | "denied" } +``` + +- `main.rs` reads this file **synchronously, before `tauri::Builder` runs**. Missing or + unparsable file ⇒ `unset` ⇒ no telemetry. +- Sentry initializes **only** when consent is `granted`. +- Two new Tauri commands expose it to the frontend: `get_telemetry_consent()` returns + both the current file value **and** `active_this_session` (the value captured at + startup — whether the Rust SDK actually initialized); `set_telemetry_consent(consent)` + updates the file. The frontend never touches the file directly and does **not** mirror + consent into `localStorage` — the file is the single source of truth. +- A change of consent takes effect **on next launch**. Granting mid-session does not + start reporting; revoking mid-session does not stop the already-initialized SDK. The + UI states this ("takes effect after restart"). This keeps the init path a single + read-once guard with no runtime toggling. + +### Rust side + +- `sentry` crate initialized in `main.rs` behind the consent guard. Default panic + integration captures panics with stack traces. +- `sentry-tauri` plugin registered with the builder so WebView events route through the + Rust SDK over Tauri IPC. One DSN, one init point, no direct network calls from the + WebView (CSP unchanged). +- `release` set to the app version (from `tauri.conf.json`, currently `0.4.x`) so events + map to specific builds. + +### Frontend side + +- Sentry JS SDK configured to hand envelopes to the `sentry-tauri` plugin transport + (per that plugin's documented setup) — initialized only when + `get_telemetry_consent()` reports `active_this_session: true`, never from the current + file value. This keeps both crash surfaces gated by the same startup snapshot: a + mid-session grant cannot start the JS SDK while the Rust SDK is inactive. +- Global error/unhandledrejection capture, plus an explicit `captureException` in + `PaneErrorBoundary.tsx` (`componentDidCatch`), so React crashes that today only render + the error pane are also reported. + +### Privacy scrubbing + +AgentPanel sees users' terminals, repos, and environment. Reported events must contain +only: exception type/message/stack, app version, OS + arch. Specifically: + +- `send_default_pii` stays off (default); no IP address stored (Sentry project setting). +- `server_name` (hostname) stripped via SDK option. +- Console breadcrumbs disabled on the JS side; no breadcrumbs that could carry terminal + output or command lines. +- A `before_send` hook (both SDKs) redacts absolute filesystem paths in exception + values and messages — home directories leak usernames; stack-frame paths are rewritten + to strip everything before the project/app root. Error **messages** are kept but + path-scrubbed; if a message cannot be scrubbed confidently it is truncated rather than + dropped. +- Terminal buffer contents, PTY I/O, env vars, and repo paths are never attached. + +### First-run prompt + +- When the frontend loads and consent is `unset`, show a non-blocking banner (same + pattern/placement as `UpdateBanner.tsx`): "Help improve AgentPanel — send anonymous + crash reports? Learn what's collected. [Yes] [No]". +- Either choice calls `set_telemetry_consent` and the banner never reappears. +- "Learn what's collected" links to the README telemetry section. + +### Settings toggle + +- New row in `SettingsModal.tsx`: checkbox "Send anonymous crash reports", reflecting + the file-backed consent, with helper text "Takes effect after restart." + +### Release plumbing (CI) + +- Extend `.github/workflows/release.yml` with a source-map upload step (Sentry CLI) + keyed by a `SENTRY_AUTH_TOKEN` repo secret, associating maps with the release version + so minified JS traces are readable. Build must emit source maps to a non-shipped + location (maps are uploaded to Sentry, not bundled into the installer). +- Step is conditional on the secret existing, so forks' release builds don't fail. + +### Disclosure + +- README gains a "Telemetry" section: opt-in only, exactly what is sent (exception + + version + OS/arch), what is never sent (terminal contents, paths, env), how to + disable, link to the Sentry project region. + +## Error handling + +- Consent file unreadable/corrupt ⇒ treated as `unset` (fail closed, no telemetry); + the first-run banner reappears. +- `set_telemetry_consent` write failure ⇒ surfaced to the frontend as a toast (existing + `Toasts.tsx`); consent remains unchanged. +- Sentry unreachable ⇒ SDK drops events silently; the app must never block, slow + startup, or surface errors because telemetry failed. + +## Testing + +- **Vitest:** banner renders only when consent is `unset`; Yes/No each call + `set_telemetry_consent` and dismiss permanently; Settings toggle reflects and updates + consent (Tauri commands mocked, consistent with existing `SettingsModal.*.test.tsx`). +- **Rust:** unit tests for the consent file read/write round-trip and the + corrupt-file ⇒ `unset` path. +- **Path scrubber:** unit tests (both sides where applicable) proving home-directory + and username redaction on representative Windows and macOS paths. +- **End-to-end (manual):** a debug-build-only hidden command triggers a test Rust panic + and a test JS error; verify both appear in Sentry with scrubbed paths and correct + release tag, and that nothing is sent when consent is `denied`/`unset` (verify via + Sentry ingest + no outbound requests). + +## Out of scope + +- Usage analytics of any kind. +- In-app "Report Issue" button / log-file export (possible follow-up). +- Runtime (no-restart) consent toggling. +- Self-hosted backends. From 1829bfad6047affa468f3e42f3b1f91aa7eb364b Mon Sep 17 00:00:00 2001 From: GrillerGeek Date: Fri, 24 Jul 2026 09:21:56 -0400 Subject: [PATCH 2/3] Add Guildhall quest plan for crash-reporting feature Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-23-crash-reporting.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/guildhall/plans/2026-07-23-crash-reporting.md diff --git a/docs/guildhall/plans/2026-07-23-crash-reporting.md b/docs/guildhall/plans/2026-07-23-crash-reporting.md new file mode 100644 index 0000000..659c068 --- /dev/null +++ b/docs/guildhall/plans/2026-07-23-crash-reporting.md @@ -0,0 +1,238 @@ +--- +quest: Implement opt-in Sentry crash reporting per the approved design spec +mode: feature +started: 2026-07-23T00:00:00Z +spec: docs/superpowers/specs/2026-07-23-crash-reporting-design.md +slug: crash-reporting +status: completed +model_check: "haiku (param honored)" +parent_model: "claude-fable-5" +--- + +# Plan + +## Context + +Spec: `docs/superpowers/specs/2026-07-23-crash-reporting-design.md` (user-approved this +session). It is a design doc, not an IDD-format spec; its **Testing**, **Architecture**, +**Error handling**, and **Out of scope** sections serve as Expectations/Boundaries. They +are concrete and verifiable, so no `spec-reviewer` detour is needed (noted as a +Mordain decision below). + +Load-bearing spec requirements (verbatim): + +> - `main.rs` reads this file **synchronously, before `tauri::Builder` runs**. Missing or +> unparsable file ⇒ `unset` ⇒ no telemetry. +> - Sentry initializes **only** when consent is `granted`. +> - Two new Tauri commands expose it to the frontend: `get_telemetry_consent()` returns +> both the current file value **and** `active_this_session` (the value captured at +> startup — whether the Rust SDK actually initialized); `set_telemetry_consent(consent)` +> updates the file. +> - A change of consent takes effect **on next launch**. + +Privacy (verbatim): `send_default_pii` off; `server_name` stripped; console breadcrumbs +disabled; `before_send` path/username redaction; terminal contents/PTY I/O/env/repo +paths never attached. + +Cited codebase patterns: +- `src-tauri/src/store.rs` — fail-soft JSON persistence style (`unwrap_or_default`, + missing/corrupt ⇒ default). The telemetry module mirrors this, but CANNOT use + `AppHandle`-based `app_data_dir()` (no handle exists pre-Builder). It resolves the + platform data dir + `com.jason.agentpanel` (identifier from `tauri.conf.json:5`) + directly — same directory Tauri resolves to. +- `src/components/UpdateBanner.tsx` — the non-blocking banner pattern the consent + prompt copies. +- `src/components/PaneErrorBoundary.tsx` — gets a `componentDidCatch` → + `captureException` hook. +- `src/components/SettingsModal.update.test.tsx` — Vitest + `vi.mock("@tauri-apps/api/core")` + invoke-mocking pattern for frontend tests. +- `src-tauri/src/lib.rs` — plugin registration + `generate_handler!` list where the two + new commands and sentry-tauri plugin register. + +## Dispatch sequence + +### Sequential build +1. [x] model-echo diagnostic — haiku honored +2. [x] Seraphine Dawnveil — test-author (sonnet) — RED verified by Mordain: Vitest + 3 new files failing (6 assertions + 2 expected unresolved imports); cargo 11 + telemetry tests failing on `todo!()` panics, crate compiles (Rust toolchain was + absent on this machine; Mordain installed rustup stable to hold the gate) +3. [x] Bruga Ironseam — feature-implementer (sonnet) — GREEN verified by Mordain: + cargo 20/20; Vitest 90 pass + exactly the 7 pre-existing store.notes failures. + Ratified deviation: `tauri-plugin-sentry` 0.5 (Tauri-v2 successor of the spec's + v1-only "sentry-tauri"); init in lib.rs::run() (main.rs is a 2-line shim); + added beforeBundleCommand + scripts/strip-sourcemaps.mjs +4. [x] Tink Whiffletree — SKIPPED: inspection of telemetry.rs / lib.rs / telemetry.ts + found convention-matching, constraint-commented code; no dup beyond the + spec-mandated Rust/TS scrubber mirror. Ceremonial dispatch declined. +5. [x] IDD idd-tech-lead-reviewer (sonnet) — first dispatch failed on agent name + (`tech-lead-reviewer` → correct: `idd-tech-lead-reviewer`); retried. Verdict: + REQUEST-CHANGES — ratified all three Bruga deviations; confirmed breadcrumb- + bypass HIGH and banner write-failure HIGH at code level; flagged DSN CI gap + + release-string mismatch as merge blockers; noted banner optimistic-dismiss can + diverge UI state from file state until restart + +### Parallel reviews (after green) — all six fired in one message +- [x] Oriana — security-reviewer (opus) — 1 HIGH, 1 med, 2 low, 3 info +- [x] Cassian — docs-writer (sonnet) — README + SECURITY.md edits applied; anchor ok +- [x] Vance — observability-reviewer (sonnet) — 1 HIGH, 2 med, 1 low, 1 info +- [x] Thalia — reliability-reviewer (opus) — 0 high, 1 med, 1 low, 6 info +- [x] Cassia — performance-reviewer (sonnet) — 1 HIGH, 2 med, 2 low, 6 info +- [x] Garran — ops-readiness-reviewer (sonnet) — runbook delivered; found DSN never + injected in release.yml + release-string mismatch breaking JS symbolication + +### Closer +- [x] Rook Mossbrook — pr-author (sonnet) — dispatched after the fix round closed + all HIGHs (halt lifted by user-approved fix round; Oriana + Vance re-verified + all their findings CLOSED). PR title "Add opt-in Sentry crash reporting" + + body emitted; user creates the PR. + +## Review findings (triaged) + +HIGH (block Rook): +1. [Oriana][security] Click/nav breadcrumbs bypass BOTH scrubbers; UI `title`/ + `aria-label` attrs (TabBar.tsx:191/160/242, Sidebar.tsx:139/177) carry absolute + cwd/repo paths, usernames, branch names, commit messages, command lines into + events verbatim. Violates spec allowlist + README promise. +2. [Vance][observability] TelemetryBanner.tsx:32-35 — consent-write failure never + surfaced (spec requires toast); optimistic dismiss + unhandled rejection. +3. [Cassia][performance] lib.rs init_telemetry pre-Builder `sentry::init` does + blocking TLS-stack construction (est. up to ~20ms) + 2 thread spawns on the + startup path for opted-in users; spec says never slow startup. + +MED (fix or note in PR): +- [Oriana] Scrubber only knows `/Users/`-rooted paths; `/home/`, `/Volumes/…`, + UNC shares pass unredacted (repo paths reachable on shipped mac/Win). +- [Vance] strip-sourcemaps.mjs bare catch swallows all readdir errors, silently + defeating its own ship-no-maps guarantee; should only swallow ENOENT. +- [Vance] (= Oriana HIGH #1, breadcrumb scrub gap, observability angle) +- [Thalia] Default reqwest transport has NO timeouts; black-holed endpoint + full + queue (30) ⇒ panic-time flush blocks the dying process forever. Fix: custom + transport with connect/request timeouts. +- [Cassia] @sentry/browser eagerly bundled/parsed for ALL users incl. non-consented + (~25-35KB gz est.); suggest dynamic import behind active_this_session. +- [Garran][ops, release-blocking in practice] AGENTPANEL_SENTRY_DSN never injected + in release.yml build env ⇒ shipped builds compile with no DSN, telemetry inert. +- [Garran][ops] Release-string mismatch: Rust `0.4.1` / JS `agentpanel@0.4.1` / + CI upload `0.4.1` ⇒ JS stack traces never symbolicate. + +LOW/INFO of note: +- [Oriana] extra.componentStack (PaneErrorBoundary) outside spec allowlist; + extra/contexts/tags are an unscrubbed sink. README IP-address claim stronger than + client can enforce (needs Sentry project setting). Windows safety-net regex + narrower than primary matcher. +- [Thalia] Normal-exit events unflushed (BY DESIGN, verified no shutdown hang); + panics delivered best-effort in ≤2s; native crashes (SIGSEGV) not covered + (minidump deliberately off — product scope note). Statics race-free. IPC + saturation cannot block PTY path. Offline buffer bounded at 30. +- [Cassia] Duplicate get_telemetry_consent invoke at boot (App + banner). + Cargo.lock oddity: sentry-actix/actix-web entries present — run + `cargo tree -e features -p sentry` to confirm not compiled in. +- [Vance] No debug-build diagnostic distinguishing no-DSN / denied / corrupt-file + inactive states. + +## Reviewers selected + +Always-on: +- Oriana (`security-reviewer`) — fires +- Cassian (`docs-writer`) — fires + +Gated: +- Vance (`observability-reviewer`) — fired — default-on for feature-implementer chains; + telemetry init/consent paths must not fail silently in ways we can't diagnose +- Thalia (`reliability-reviewer`) — fired — diff adds network I/O (Sentry transport) + and a startup-critical read +- Cassia (`performance-reviewer`) — fired — spec mandates telemetry "never block, slow + startup"; latency mention ⇒ bias-to-fire +- Garran (`ops-readiness-reviewer`) — fired — user-visible behavior change shipping in + next release; DSN/secret setup needs a runbook +- Ysolde (`migration-safety-reviewer`) — skipped — no migrations, schema, or backfills +- Vera (`ui-test-author`) — skipped — repo has no Playwright harness (Tauri desktop app; + UI tests are Vitest + testing-library per existing pattern); spec's UI expectations + are covered by Seraphine's component tests +- Lior (`accessibility-reviewer`) — skipped — gate pairs Lior with Vera; banner/toggle + reuse existing accessible patterns, and Seraphine's tests assert roles/labels + +## Fix round (2026-07-24, user-approved) + +Bruga resumed with a 12-item scoped list (a-l). 11 implemented; GREEN re-verified +by Mordain: cargo 25/25 (+5 scrubber-shape tests), Vitest 96 pass + exactly the 7 +pre-existing store.notes failures (+6 new tests). Bruga also confirmed +`npm run build` + `tsc --noEmit` clean and the dynamic @sentry/browser chunk split +(main bundle ~332KB → ~246KB). + +- (a) breadcrumbs: JS Breadcrumbs integration dropped entirely + + beforeBreadcrumb→sendBreadcrumbToRust removed; Rust scrub_event now scrubs + event.breadcrumbs (defense-in-depth) +- (b) banner .catch → revert to unset + pushToast; reject-flow test added +- (c/g) TimeoutTransportFactory with connect/request timeouts wired via + ClientOptions.transport; SENTRY_GUARD droppability warning comment. + LAZY construction judged infeasible by Bruga (sentry 0.42 private worker/flush + semantics; unverifiable hand-off logic) — MORDAIN RULING: accepted. With + timeouts in place, Thalia's hang scenario is closed; what remains of Cassia's + HIGH is a bounded one-time TLS-stack build (est. ≤~20ms) on startup, only for + opted-in users on DSN-bearing builds. Documented as accepted cost in PR body. +- (d) AGENTPANEL_SENTRY_DSN injected into release.yml build env (secrets) +- (e) release strings aligned to agentpanel@ in Rust + CI (JS already) +- (f) strip-sourcemaps.mjs: ENOENT-only catch, rethrows otherwise +- (h) scrubbers extended: /home/, /root, /Volumes/, UNC shares; + widened truncation backstop incl. lowercase-drive/forward-slash Windows +- (i/j) @sentry/browser dynamically imported behind active_this_session; + PaneErrorBoundary uses new captureError export; componentStack extra dropped +- (k) README IP claim softened to client-enforceable wording +- (l) debug-build-only eprintln! diagnostics for inactive-telemetry causes + +Re-verification: Oriana + Vance resumed for focused CLOSED/STILL-OPEN verdicts +on their findings before Rook. + +## Decisions made by Mordain + +- Treat the approved design doc as the spec despite non-IDD format — its Testing/Error + handling/Out of scope sections are concrete Expectations/Boundaries; routing to + spec-author would re-litigate a user-approved design. +- Consent path resolution: platform data dir + `com.jason.agentpanel` resolved without + `AppHandle` (pre-Builder constraint) — new `telemetry.rs` module keeps pure, + path-parameterized functions so tests need no Tauri runtime. +- DSN strategy: compile-time `option_env!("AGENTPANEL_SENTRY_DSN")` (and Vite env for + any frontend need). Absent/empty DSN ⇒ Sentry never initializes even with consent + granted — dev builds and forks stay telemetry-free by default. User must create the + Sentry project and set the env in CI (open item). +- Skipped Aldric — architecture was settled with the user during brainstorming; no + competing in-repo patterns to arbitrate. +- Skipped Vera/Lior — no Playwright harness exists; component-level coverage suffices + for a banner + toggle (reasons above). + +## Open items for the user + +QUEST HALTED before PR draft (hard rule: HIGH findings ⇒ stop and surface). Decide: + +1. Fix round (recommended): re-dispatch Bruga with a scoped fix list — + (a) scrub or disable DOM/nav breadcrumbs [Oriana HIGH]; + (b) banner .catch → toast + revert on write failure [Vance HIGH]; + (c) move sentry::init off the pre-Builder blocking path or accept documented + one-time ~ms-20ms cost for opted-in users [Cassia HIGH — cheap fix vs accept]; + (d) inject AGENTPANEL_SENTRY_DSN into release.yml build env [Garran]; + (e) align release strings (Rust/JS/CI upload) [Garran]; + (f) strip-sourcemaps.mjs: swallow only ENOENT [Vance med]; + (g) transport connect/request timeouts [Thalia med]; + optionally (h) broaden scrubber beyond /Users/ paths, (i) dynamic-import + @sentry/browser, (j) drop componentStack extra. +2. Or accept specific findings as-is (each is fail-closed/privacy-safe in the + current no-DSN state, but README promises would be false once a DSN ships). + +External setup only the maintainer can do: +- Create the Sentry org/project (pick data region; enable "do not store IP" + project setting to match README claim). +- Add SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT secrets + AGENTPANEL_SENTRY_DSN + to the repo; decide secret vs variable for the DSN. +- Configure Sentry alert rules + key rate limits (free tier: 5k events/mo). +- First release: manually verify a test Rust panic and a test JS error arrive + scrubbed and symbolicated (no automated coverage of the full tauri build). + +Environment notes: +- Pre-existing unrelated failure: src/state/store.notes.test.ts (7 tests, + localStorage.clear not a function) — fails on clean checkout too. +- Rust toolchain (rustup stable, minimal profile) was installed on this machine + by Mordain to run the Rust gates; it was absent before this quest. +- Cargo.lock sanity check suggested: `cargo tree -e features -p sentry` re + unexpected sentry-actix entries (Cassia info). From f3e32456d167a9810c7fb481ccac3e2d7ed54513 Mon Sep 17 00:00:00 2001 From: GrillerGeek Date: Fri, 24 Jul 2026 09:59:28 -0400 Subject: [PATCH 3/3] Add opt-in Sentry crash reporting Rust panics and WebView errors reported via Sentry, strictly opt-in: first-run consent banner, Settings toggle, Rust-owned consent file read synchronously before tauri::Builder. Consent changes apply on next launch; both SDKs gate on the startup snapshot. Privacy: no default PII, hostname stripped, all automatic breadcrumbs disabled, before_send path/username scrubbing on both SDKs (Users/home/ root/Volumes/UNC shapes), terminal contents never touched. DSN compiled in only at CI release build (AGENTPANEL_SENTRY_DSN); dev builds and forks stay telemetry-inert. Transport has connect/request timeouts; source maps uploaded to Sentry and stripped from the installer. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 29 + README.md | 37 + SECURITY.md | 4 + package-lock.json | 126 ++- package.json | 2 + scripts/strip-sourcemaps.mjs | 36 + src-tauri/Cargo.lock | 773 +++++++++++++++++- src-tauri/Cargo.toml | 20 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 169 +++- src-tauri/src/telemetry.rs | 392 +++++++++ src-tauri/tauri.conf.json | 1 + src/App.tsx | 10 + src/components/PaneErrorBoundary.tsx | 9 + .../SettingsModal.telemetry.test.tsx | 76 ++ src/components/SettingsModal.tsx | 41 + src/components/TelemetryBanner.test.tsx | 92 +++ src/components/TelemetryBanner.tsx | 65 ++ src/lib/telemetry.test.ts | 69 ++ src/lib/telemetry.ts | 183 +++++ vite.config.ts | 8 + 21 files changed, 2120 insertions(+), 25 deletions(-) create mode 100644 scripts/strip-sourcemaps.mjs create mode 100644 src-tauri/src/telemetry.rs create mode 100644 src/components/SettingsModal.telemetry.test.tsx create mode 100644 src/components/TelemetryBanner.test.tsx create mode 100644 src/components/TelemetryBanner.tsx create mode 100644 src/lib/telemetry.test.ts create mode 100644 src/lib/telemetry.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46445ee..7358ba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,31 @@ jobs: - name: Install frontend dependencies run: npm ci + # Crash reporting (docs/superpowers/specs/2026-07-23-crash-reporting-design.md): + # builds once here so minified-JS stack traces can be mapped back to + # source in Sentry. Conditional on the secret so forks (which don't have + # it) still build releases; the actual bundled build's source maps are + # stripped before packaging regardless (tauri.conf.json's + # `beforeBundleCommand` / scripts/strip-sourcemaps.mjs), so this step + # never causes maps to ship inside the installer either way. + - name: Build web assets (for source-map upload) + if: ${{ secrets.SENTRY_AUTH_TOKEN != '' }} + run: npm run build + + - name: Upload source maps to Sentry + if: ${{ secrets.SENTRY_AUTH_TOKEN != '' }} + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + # Must match the Rust side's `release` string exactly (src-tauri/src/lib.rs's + # init_telemetry) and the JS SDK's (src/lib/telemetry.ts's initTelemetry) -- + # otherwise traces from one side won't symbolicate against these maps. + npx --yes @sentry/cli sourcemaps upload \ + --release "agentpanel@${GITHUB_REF_NAME#v}" \ + dist + - name: Build the app uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: @@ -92,6 +117,10 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # Compiled in as `option_env!("AGENTPANEL_SENTRY_DSN")` (src-tauri/src/lib.rs). + # Empty-if-unset keeps forks (no access to this secret) fail-closed -- + # they still build releases, just with telemetry compiled inert. + AGENTPANEL_SENTRY_DSN: ${{ secrets.AGENTPANEL_SENTRY_DSN }} with: tagName: ${{ github.ref_name }} releaseName: "AgentPanel ${{ github.ref_name }}" diff --git a/README.md b/README.md index c155f38..5688faa 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,43 @@ cargo test --manifest-path src-tauri/Cargo.toml # Rust (git/gh layer) The frontend talks to Rust through Tauri **commands** and streams PTY output over a **Channel**. +## Telemetry + +AgentPanel can optionally report crashes and errors to help catch bugs in the wild. It is +**off by default** and strictly **opt-in** — you're asked once, on first run, and nothing is +sent until you say yes. + +**What's sent, if you opt in:** +- The exception type, message, and stack trace +- The app version +- Your OS and CPU architecture + +**What's never sent:** +- Terminal buffer contents or PTY input/output +- Environment variables +- Repository paths or file contents — absolute filesystem paths are stripped from every report + (usernames and home directories are redacted; stack-frame paths are trimmed to the project-relative + portion) before it leaves your machine +- Your hostname, or any other usage/analytics data — this is crash reporting only, never feature + or usage tracking +- Reports don't include your IP address, and the Sentry project is configured not to store it — + note this is a project-level setting on the receiving end, not something the app itself can + enforce over the network + +**How it works:** consent lives in a small local file +(`telemetry.json` in AgentPanel's app-data directory), not in browser storage, because the choice +has to be known before the app can even start crash reporting. Changing the setting takes effect +on your next launch. Reporting is also only wired up in official released builds — the Sentry +endpoint is baked in at release-build time, so builds from source (including forks and +`npm run tauri dev`) have none configured and send nothing, regardless of the toggle. + +**To disable it:** uncheck "Send anonymous crash reports" in Settings, or answer "No" on the +first-run prompt. To change your answer later, use the Settings toggle — either way, restart +AgentPanel for the change to take effect. + +Reports go to a Sentry project operated by the maintainer. See the [issue tracker](../../issues) +if you'd like to ask what region/retention that project uses. + ## License [MIT](LICENSE) © 2026 Jason Robey diff --git a/SECURITY.md b/SECURITY.md index 803fafe..39d1d4c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,3 +34,7 @@ repositories you point it at — all with the privileges of the launching user. app's own behavior (e.g. command injection, unsafe path handling, insecure IPC between the frontend and the Rust core, or credential leakage) are in scope. The behavior of third-party agent CLIs you choose to run inside AgentPanel is not. + +Crash reporting (see the README's [Telemetry](README.md#telemetry) section) is opt-in and scrubs +absolute filesystem paths before a report leaves your machine. A bug that let a report escape with +an unredacted path, username, or other data outside what's documented there is in scope. diff --git a/package-lock.json b/package-lock.json index d0bbbc7..f62d5e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "agentpanel", - "version": "0.3.2", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentpanel", - "version": "0.3.2", + "version": "0.4.1", "dependencies": { + "@sentry/browser": "^10.67.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", @@ -21,6 +22,7 @@ "@xterm/xterm": "^5.5.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "tauri-plugin-sentry-api": "^0.5.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -1399,6 +1401,95 @@ "win32" ] }, + "node_modules/@sentry/browser": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.67.0.tgz", + "integrity": "sha512-/ZhsAvte4rYhg0A0RtSFFgAgXhyMOfQIeOAfMfptN+X6IVSYOfkA9jtrP+Ej4+6vlaUFWRir1HweF56y63dEEA==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.67.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.67.0", + "@sentry/feedback": "10.67.0", + "@sentry/replay": "10.67.0", + "@sentry/replay-canvas": "10.67.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.67.0.tgz", + "integrity": "sha512-HUzaf0xAnPAB+OHBkD7N1Py+CTbD5InHulQ/pdhX4JctWtxuwD8odMD1LzdPnW8J6gVHlDVvcVBR8mXMZYSLSw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.67.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.67.0.tgz", + "integrity": "sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.67.0.tgz", + "integrity": "sha512-I4ML2/SF3enwikb6ZSoRiqolQrx0zSzTSnUgwCmugICF/jpHW0th1pCray9R+t1Zzibw/Dpj4t/DNXaSDRa2MA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.67.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.67.0.tgz", + "integrity": "sha512-nkEUgPCR82EcyJkCf3XCE9H0R5KisCqyCAaSGxe7NpAoQbvASHx4MUNgXVAn+D0M494gvPZh6lFH7JgzqTcSqQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.67.0", + "@sentry/core": "10.67.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.67.0.tgz", + "integrity": "sha512-neNA4T6MFtZzMdKYetiR+LZd9BNSd0q2szMn0wk+A15PqHE/IN7a34V6JZc9rCtmzB0wldh0eWGOBb49MSNKjA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.67.0", + "@sentry/replay": "10.67.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2801,6 +2892,31 @@ "dev": true, "license": "MIT" }, + "node_modules/tauri-plugin-sentry-api": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/tauri-plugin-sentry-api/-/tauri-plugin-sentry-api-0.5.0.tgz", + "integrity": "sha512-IUOMOQZafbQJbPS9eisdABt9tdJksec9ajOQRlM31DfOcdzayaUis7FK48AD5ayeNCOwJDZPs1nvlZSD8jFYiQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "@tauri-apps/api": "2.0.0-beta.8", + "tslib": "^2.6.0" + } + }, + "node_modules/tauri-plugin-sentry-api/node_modules/@tauri-apps/api": { + "version": "2.0.0-beta.8", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.0-beta.8.tgz", + "integrity": "sha512-fN5u+9HsSfhRaXGOdD2kPGbqDgyX+nkm1XF/4d/LNuM4WiNfvHjjRAqWQYBhQsg1aF9nsTPmSW+tmy+Yn5T5+A==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">= 18", + "npm": ">= 6.6.0", + "yarn": ">= 1.19.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2891,6 +3007,12 @@ "node": ">=20" } }, + "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==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/package.json b/package.json index c8bb59a..0a18b5a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "tauri": "tauri" }, "dependencies": { + "@sentry/browser": "^10.67.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", @@ -24,6 +25,7 @@ "@xterm/xterm": "^5.5.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "tauri-plugin-sentry-api": "^0.5.0", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/scripts/strip-sourcemaps.mjs b/scripts/strip-sourcemaps.mjs new file mode 100644 index 0000000..e186a5c --- /dev/null +++ b/scripts/strip-sourcemaps.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Wired as tauri.conf.json's `build.beforeBundleCommand`: runs after the +// frontend + Rust build, right before Tauri packages `dist/` into the +// installer. Source maps must reach Sentry (uploaded separately by CI, see +// .github/workflows/release.yml) but must never ship inside the app -- +// this is the one point in `tauri build` guaranteed to run after Vite +// (re-)emits them and before bundling embeds `dist/`, regardless of how many +// times the frontend gets rebuilt earlier in the process. +import { readdirSync, rmSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const distDir = new URL("../dist", import.meta.url).pathname; + +function removeMapsRecursive(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch (err) { + // Only a missing dist/ (e.g. the frontend build step never ran) is + // expected and safe to skip. Anything else (permissions, I/O errors) must + // fail the build loudly rather than silently ship source maps -- a + // swallowed error here defeats the whole ship-no-maps guarantee. + if (err.code === "ENOENT") return; + throw err; + } + for (const entry of entries) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + removeMapsRecursive(full); + } else if (entry.endsWith(".map")) { + rmSync(full, { force: true }); + } + } +} + +removeMapsRecursive(distDir); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a1f93cd..e355c63 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,163 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags 2.13.0", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "foldhash 0.2.0", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio 1.2.1", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes", + "bytestring", + "cfg-if", + "derive_more", + "encoding_rs", + "foldhash 0.2.0", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.4", + "time", + "tracing", + "url", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -16,6 +173,9 @@ dependencies = [ "libc", "notify", "portable-pty", + "regex", + "reqwest 0.12.28", + "sentry", "serde", "serde_json", "tauri", @@ -25,6 +185,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-sentry", "tauri-plugin-updater", "winreg 0.52.0", ] @@ -264,6 +425,21 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -276,6 +452,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -400,6 +582,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -510,6 +701,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -550,6 +747,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -585,7 +791,7 @@ dependencies = [ "bitflags 2.13.0", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -733,6 +939,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -768,10 +994,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.118", + "unicode-xid", ] [[package]] @@ -943,6 +1171,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1082,6 +1319,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1116,6 +1365,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1123,7 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1137,6 +1395,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1168,6 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1398,6 +1663,12 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -1602,6 +1873,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1612,6 +1894,17 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -1629,7 +1922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -1640,7 +1933,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -1651,6 +1944,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1661,7 +1960,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", + "http 1.4.2", "http-body", "httparse", "itoa", @@ -1677,7 +1976,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -1686,6 +1985,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1696,14 +2011,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.2", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -1866,6 +2181,12 @@ dependencies = [ "tiff", ] +[[package]] +name = "impl-more" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" + [[package]] name = "indexmap" version = "1.9.3" @@ -2119,6 +2440,12 @@ dependencies = [ "libc", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -2195,6 +2522,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.14" @@ -2300,6 +2633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2335,6 +2669,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2379,6 +2730,18 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -2667,6 +3030,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2685,12 +3057,49 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2707,6 +3116,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix 0.31.3", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2791,6 +3216,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2956,7 +3390,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.25.1", "serial", "shared_library", "shell-words", @@ -3205,12 +3639,56 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -3221,7 +3699,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -3288,6 +3766,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3395,6 +3879,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3522,6 +4012,127 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sentry" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" +dependencies = [ + "httpdate", + "native-tls", + "reqwest 0.12.28", + "sentry-actix", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-actix" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c675bdf6118764a8e265c3395c311b4d905d12866c92df52870c0223d2ffc1" +dependencies = [ + "actix-http", + "actix-web", + "bytes", + "futures-util", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" +dependencies = [ + "rand", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-debug-images" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00950648aa0d371c7f57057434ad5671bd4c106390df7e7284739330786a01b6" +dependencies = [ + "findshlibs", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" +dependencies = [ + "bitflags 2.13.0", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" +dependencies = [ + "debugid", + "hex", + "rand", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.228" @@ -3617,6 +4228,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3805,6 +4428,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -4054,7 +4687,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.4.2", "jni 0.21.1", "libc", "log", @@ -4068,7 +4701,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -4275,6 +4908,21 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-sentry" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7432b519b6d2d027082a940783c61ba9b684b38d8df7558cd5f924d87009b295" +dependencies = [ + "base64 0.22.1", + "schemars 0.8.22", + "sentry", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4285,13 +4933,13 @@ dependencies = [ "dirs", "flate2", "futures-util", - "http", + "http 1.4.2", "infer", "log", "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -4317,7 +4965,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.4.2", "jni 0.21.1", "objc2", "objc2-ui-kit", @@ -4340,7 +4988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ "gtk", - "http", + "http 1.4.2", "jni 0.21.1", "log", "objc2", @@ -4372,7 +5020,7 @@ dependencies = [ "dom_query", "dunce", "glob", - "http", + "http 1.4.2", "infer", "json-patch", "log", @@ -4570,11 +5218,23 @@ dependencies = [ "bytes", "libc", "mio 1.2.1", + "parking_lot", "pin-project-lite", - "socket2", + "signal-hook-registry", + "socket2 0.6.4", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4742,7 +5402,7 @@ dependencies = [ "bitflags 2.13.0", "bytes", "futures-util", - "http", + "http 1.4.2", "http-body", "pin-project-lite", "tower", @@ -4769,6 +5429,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4792,6 +5453,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "tracing-core", ] [[package]] @@ -4856,6 +5527,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4909,12 +5589,47 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.2", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4946,6 +5661,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4964,6 +5685,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5884,7 +6617,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http", + "http 1.4.2", "javascriptcore-rs", "jni 0.21.1", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a8a75e..1fcb2e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,26 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Path scrubbing for the opt-in crash-reporting feature (telemetry.rs). +regex = "1" + +# Opt-in crash reporting (docs/superpowers/specs/2026-07-23-crash-reporting-design.md). +# Default features pull in the panic integration + reqwest/native-tls transport. +sentry = "0.42" +# Tauri-v2-compatible plugin (note: the design doc's working name "sentry-tauri" +# refers to this crate's older Tauri-v1-only ancestor of the same name on +# crates.io; `tauri-plugin-sentry` is its Tauri v2 successor by the same author, +# same architecture -- WebView events routed to the Rust SDK over Tauri IPC). +# default-features off: this crate's default "minidump" feature captures native +# crash dumps via a separate crash-reporter process, which is out of scope here +# (spec covers Rust panics + WebView JS exceptions only). +tauri-plugin-sentry = { version = "0.5", default-features = false } +# Direct dependency only so lib.rs can build a `reqwest::Client` with explicit +# connect/request timeouts for `TimeoutTransportFactory` (see lib.rs). The TLS +# backend feature is already enabled via `sentry`'s own dependency edge +# (its default "native-tls" feature); Cargo unifies features across the one +# resolved `reqwest` version in the graph, so this entry doesn't restate it. +reqwest = { version = "0.12", default-features = false } # Terminal spike (Phase 0): cross-platform PTY -> ConPTY on Windows. portable-pty = "0.8" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index edfef5c..bec7163 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text", "updater:default", - "process:allow-restart" + "process:allow-restart", + "sentry:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b01e33d..3cf9ce2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,12 +6,163 @@ mod model; mod pty; mod shells; mod store; +mod telemetry; mod watcher; +use std::ops::Deref; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + use store::AppStore; use tauri::Manager; +use telemetry::Consent; use watcher::WatcherState; +/// Whether the Rust Sentry SDK actually initialized this session -- captured +/// once at startup (see `init_telemetry`), before `tauri::Builder` runs. +/// `get_telemetry_consent` exposes this so the frontend's JS SDK init is gated +/// on the same startup snapshot rather than the (possibly since-changed) file. +static ACTIVE_THIS_SESSION: OnceLock = OnceLock::new(); + +/// Holds the Sentry client alive for the process lifetime. Dropping the +/// `ClientInitGuard` flushes and disables reporting, so it must never go out +/// of scope; a `OnceLock` set once in `run()` and never read again does that. +/// IMPORTANT: never make this droppable (e.g. by moving it into a scoped +/// variable, or wiring a graceful-shutdown path that calls `.close()`) unless +/// the transport it holds still has connect/request timeouts -- a transport +/// without them can hang the dying process on a blocking queue send while +/// draining on drop (see `TimeoutTransportFactory`). +static SENTRY_GUARD: OnceLock = OnceLock::new(); + +pub(crate) fn telemetry_active_this_session() -> bool { + *ACTIVE_THIS_SESSION.get().unwrap_or(&false) +} + +/// Compile-time DSN (docs/superpowers/specs/2026-07-23-crash-reporting-design.md). +/// Absent or empty (dev builds, forks without the `AGENTPANEL_SENTRY_DSN` build +/// secret) means Sentry never initializes even when consent is `granted` -- +/// there is no real DSN checked into this repo. +fn sentry_dsn() -> Option<&'static str> { + option_env!("AGENTPANEL_SENTRY_DSN").filter(|s| !s.is_empty()) +} + +/// Redact absolute filesystem paths (which leak OS usernames) from an +/// outgoing event's message, exception values/stack-frame paths, and +/// breadcrumbs (message + any string values in `data`) before it is sent. +/// Kept, not dropped -- see `telemetry::scrub_paths` for the rules. +/// +/// Breadcrumbs get scrubbed here as defense-in-depth: the JS side disables +/// automatic breadcrumb capture entirely (see src/lib/telemetry.ts), but the +/// Rust SDK's own default integrations (panic, etc.) can still add +/// breadcrumbs directly in this process, bypassing the JS gate. +fn scrub_event(mut event: sentry::protocol::Event<'static>) -> Option> { + if let Some(msg) = event.message.take() { + event.message = Some(telemetry::scrub_paths(&msg)); + } + for exception in event.exception.iter_mut() { + if let Some(value) = exception.value.take() { + exception.value = Some(telemetry::scrub_paths(&value)); + } + if let Some(stacktrace) = exception.stacktrace.as_mut() { + for frame in stacktrace.frames.iter_mut() { + if let Some(filename) = frame.filename.take() { + frame.filename = Some(telemetry::scrub_paths(&filename)); + } + if let Some(abs_path) = frame.abs_path.take() { + frame.abs_path = Some(telemetry::scrub_paths(&abs_path)); + } + } + } + } + for breadcrumb in event.breadcrumbs.iter_mut() { + if let Some(message) = breadcrumb.message.take() { + breadcrumb.message = Some(telemetry::scrub_paths(&message)); + } + for value in breadcrumb.data.values_mut() { + if let sentry::protocol::value::Value::String(s) = value { + *s = telemetry::scrub_paths(s); + } + } + } + Some(event) +} + +/// Sentry's default reqwest transport (1) builds its `reqwest::Client` (TLS +/// stack setup) synchronously inside `create_transport`, which runs during +/// `sentry::init()` -- i.e. on the startup path, before `tauri::Builder` -- +/// and (2) has no connect/request timeouts, so a black-holed endpoint can +/// leave its internal worker stuck and its bounded envelope queue full, +/// which can in turn block a caller's `send_envelope` (e.g. at panic time) on +/// that queue's blocking send. +/// +/// This factory fixes (2) directly: the `reqwest::Client` it hands to +/// `ReqwestHttpTransport` has explicit connect + request timeouts, so the +/// worker can never get stuck indefinitely and the queue keeps draining. +/// +/// It does NOT fix (1) with a lazy/background-thread-deferred client build. +/// A safe version of that would need to reimplement `ReqwestHttpTransport`'s +/// internal queue/flush/shutdown semantics (both are private to the `sentry` +/// crate) to correctly hand off buffered envelopes once the real client is +/// ready, without a lost-envelope or double-flush race -- reported to Mordain +/// as infeasible to build and verify with confidence in this pass rather than +/// risk a subtly-broken telemetry pipeline. The startup cost this leaves in +/// place is a one-time, single-threaded TLS-stack build (not a per-request or +/// indefinite one), bounded to the pre-Builder init window. +struct TimeoutTransportFactory; + +impl sentry::TransportFactory for TimeoutTransportFactory { + fn create_transport(&self, options: &sentry::ClientOptions) -> Arc { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build reqwest client for Sentry transport"); + Arc::new(sentry::transports::ReqwestHttpTransport::with_client(options, client)) + } +} + +/// Read consent and initialize Sentry (if granted and a DSN is compiled in), +/// synchronously before `tauri::Builder` runs -- the panic hook has to be +/// installed before anything can panic. Missing/corrupt consent file reads as +/// `Unset` (fail closed). Returns whether telemetry is actually active this +/// session; this is the single read-once guard -- nothing here re-checks +/// consent mid-session (a change takes effect on next launch). +fn init_telemetry() -> bool { + let consent = telemetry::read_consent(&telemetry::consent_path()); + let Some(dsn) = sentry_dsn() else { + #[cfg(debug_assertions)] + eprintln!("telemetry inactive: no AGENTPANEL_SENTRY_DSN compiled in"); + return false; + }; + if consent != Consent::Granted { + #[cfg(debug_assertions)] + eprintln!("telemetry inactive: consent is {consent:?}, not granted"); + return false; + } + + // Release format must match the JS SDK's (see src/lib/telemetry.ts's + // `initTelemetry`) and the CI source-map upload's `--release` value + // (.github/workflows/release.yml) exactly, or traces from one side won't + // symbolicate against maps/releases uploaded under the other's name. + let release = format!("agentpanel@{}", env!("CARGO_PKG_VERSION")); + + let guard = sentry::init(( + dsn, + sentry::ClientOptions { + release: Some(release.into()), + send_default_pii: false, // no IP address stored + // Prevent the default `contexts` integration from auto-filling the + // hostname (it only does so when this is `None`). + server_name: Some("".into()), + before_send: Some(Arc::new(scrub_event)), + transport: Some(Arc::new(TimeoutTransportFactory)), + ..Default::default() + }, + )); + let _ = SENTRY_GUARD.set(guard); + true +} + /// True when launched with AGENTPANEL_BENCH=1 — the frontend then auto-runs the /// perf benchmark and reports results via `write_bench`. #[tauri::command] @@ -27,13 +178,25 @@ fn write_bench(data: String) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + // Must run before the builder: the panic hook (installed by `sentry::init`) + // has to be in place before anything can panic, and consent has to be + // read synchronously since no `AppHandle` exists yet to do it async. + let active_this_session = init_telemetry(); + let _ = ACTIVE_THIS_SESSION.set(active_this_session); + + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_process::init()); + // Routes WebView (JS SDK) events through the Rust SDK over Tauri IPC -- + // only meaningful (and only registered) once the Rust SDK is actually up. + if let Some(client) = SENTRY_GUARD.get().map(|g| g.deref()) { + builder = builder.plugin(tauri_plugin_sentry::init(client)); + } + builder .manage(pty::PtyManager::default()) .manage(AppStore::default()) .manage(WatcherState::default()) @@ -64,6 +227,8 @@ pub fn run() { fonts::list_fonts, bench_requested, write_bench, + telemetry::get_telemetry_consent, + telemetry::set_telemetry_consent, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs new file mode 100644 index 0000000..d011e73 --- /dev/null +++ b/src-tauri/src/telemetry.rs @@ -0,0 +1,392 @@ +//! Telemetry consent persistence and privacy scrubbing for the opt-in Sentry +//! crash-reporting feature. +//! +//! Consent lives in a small Rust-owned JSON file (sibling of `store.rs`'s +//! data) in the app config directory, e.g. `telemetry.json`: +//! `{ "consent": "unset" | "granted" | "denied" }`. `main.rs` reads this file +//! synchronously, before `tauri::Builder` runs; missing or unparsable file +//! means `Unset`, which means no telemetry (fail closed). Sentry initializes +//! only when consent is `Granted`; a change of consent takes effect on next +//! launch. +//! +//! `scrub_paths` redacts absolute filesystem paths (which leak OS usernames +//! via the home directory) from exception values/messages before they are +//! sent, for both the Rust and TypeScript/JS Sentry SDKs. +//! +//! See docs/superpowers/specs/2026-07-23-crash-reporting-design.md. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use regex::Regex; +use serde::{Deserialize, Serialize}; + +/// Stored/transmitted as a lowercase string: `"unset"`, `"granted"`, `"denied"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Consent { + Unset, + Granted, + Denied, +} + +/// On-disk shape of `telemetry.json`. +#[derive(Serialize, Deserialize)] +struct StoredConsent { + consent: Consent, +} + +/// Bundle identifier from `tauri.conf.json`'s `identifier` field -- the same +/// value Tauri's own `app_data_dir()` joins onto the platform data dir. +const BUNDLE_ID: &str = "com.jason.agentpanel"; + +/// Resolve the real `telemetry.json` location. No `AppHandle` exists yet at +/// the point this is needed (before `tauri::Builder` runs), so this +/// recomputes the same directory Tauri's `app_data_dir()` would resolve to, +/// rather than going through Tauri. +pub fn consent_path() -> PathBuf { + platform_data_dir().join(BUNDLE_ID).join("telemetry.json") +} + +#[cfg(target_os = "windows")] +fn platform_data_dir() -> PathBuf { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +#[cfg(target_os = "macos")] +fn platform_data_dir() -> PathBuf { + std::env::var_os("HOME") + .map(|home| PathBuf::from(home).join("Library/Application Support")) + .unwrap_or_else(|| PathBuf::from(".")) +} + +// minimal: XDG fallback only; Linux isn't a shipped bundle target today (see +// tauri.conf.json's bundle.targets), but this keeps dev builds/CI on Linux +// runners resolvable instead of panicking. +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn platform_data_dir() -> PathBuf { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Read consent from `path`. A missing file or one that fails to parse +/// (corrupt JSON, or an unrecognized `consent` value) is treated as `Unset` +/// (fail closed, no telemetry) -- never an error. +pub fn read_consent(path: &Path) -> Consent { + let Ok(data) = fs::read_to_string(path) else { + return Consent::Unset; + }; + serde_json::from_str::(&data) + .map(|s| s.consent) + .unwrap_or(Consent::Unset) +} + +/// Persist `consent` to `path` as JSON. +pub fn write_consent(path: &Path, consent: Consent) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let json = + serde_json::to_string_pretty(&StoredConsent { consent }).map_err(|e| e.to_string())?; + fs::write(path, json).map_err(|e| e.to_string()) +} + +/// Path segments that mark the project/app root in a stack frame -- when one +/// of these appears in a path, everything before it is stripped rather than +/// just redacting the home directory. +fn is_project_marker(segment: &str) -> bool { + matches!(segment, "agentpanel" | "AgentPanel" | "src-tauri" | "src") +} + +/// Matches an absolute path rooted at any recognized home/mount prefix: +/// macOS/Unix `/Users/name/...`, Windows `C:\Users\name\...` or +/// `C:/Users/name/...` (any drive letter case, either separator), +/// Linux `/home/name/...` and `/root` (no separate username segment -- it's +/// the root account's own home dir), macOS volume mounts `/Volumes/name/...`, +/// and Windows UNC shares `\\server\share\...`. Captures the whole +/// whitespace-delimited path token so trailing `:line:col` survives redaction. +fn home_path_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?:/Users/|/home/|/Volumes/|/root\b|[A-Za-z]:[\\/]Users[\\/]|\\\\[^\\/\s]+\\[^\\/\s]+)[^\s]*", + ) + .unwrap() + }) +} + +/// Safety-net check used after the main redaction pass: if any of the +/// recognized home/mount prefixes still survives (any drive-letter case, +/// either Windows separator), the text couldn't be scrubbed confidently. +fn unscrubbed_marker_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?:/Users/|/home/|/Volumes/|/root\b|[A-Za-z]:[\\/]Users[\\/]|\\\\[^\\/\s]+\\[^\\/\s]+)", + ) + .unwrap() + }) +} + +/// How many leading path segments make up the home/mount prefix to fold into +/// `~`, given the token already split on its separator. Two-segment prefixes +/// (`Users`/`home`/`Volumes` + the following name segment) fold both; `root` +/// is a one-segment prefix (`/root` **is** the home dir, no name follows); +/// UNC shares fold the leading `("", "", server, share)` run. +fn home_prefix_fold_len(segments: &[&str]) -> Option { + if let Some(idx) = segments.iter().position(|s| matches!(*s, "Users" | "home" | "Volumes")) { + return Some((idx + 2).min(segments.len())); + } + if let Some(idx) = segments.iter().position(|s| *s == "root") { + return Some(idx + 1); + } + if segments.len() >= 4 && segments[0].is_empty() && segments[1].is_empty() { + // Leading "\\server\share" splits (on '\\') into ["", "", server, share, ...]. + return Some(4); + } + None +} + +/// Redact a single matched path token: strip everything before a project-root +/// marker segment when one is present; otherwise fall back to home/mount +/// redaction (replace the recognized prefix with `~`). +fn redact_path_token(token: &str) -> String { + let sep = if token.contains('\\') { '\\' } else { '/' }; + let sep_str = sep.to_string(); + let segments: Vec<&str> = token.split(sep).collect(); + + if let Some(marker_idx) = segments.iter().position(|s| is_project_marker(s)) { + return segments[marker_idx..].join(&sep_str); + } + + match home_prefix_fold_len(&segments) { + Some(fold_len) => { + let rest = &segments[fold_len..]; + if rest.is_empty() { + "~".to_string() + } else { + format!("~{sep}{}", rest.join(&sep_str)) + } + } + None => token.to_string(), + } +} + +/// Redact home-directory/mount-point segments from Windows-, macOS- and +/// Linux-style absolute paths appearing anywhere in `input` (exception +/// values, messages, stack frames) -- see `home_path_re` for the recognized +/// prefixes. Non-path text is left intact. If, after redaction, one of those +/// prefixes still survives (i.e. the message can't be scrubbed with +/// confidence), the string is truncated at that point rather than sent with a +/// username/host attached. +pub fn scrub_paths(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut last = 0; + for m in home_path_re().find_iter(input) { + out.push_str(&input[last..m.start()]); + out.push_str(&redact_path_token(m.as_str())); + last = m.end(); + } + out.push_str(&input[last..]); + + if let Some(m) = unscrubbed_marker_re().find(&out) { + out.truncate(m.start()); + } + out +} + +/// Response shape for the `get_telemetry_consent` command: the current file +/// value plus whether the Rust SDK actually initialized this session (the +/// value captured at startup, before this file could have changed). +#[derive(Debug, Clone, Serialize)] +pub struct TelemetryConsentInfo { + pub consent: Consent, + pub active_this_session: bool, +} + +#[tauri::command] +pub fn get_telemetry_consent() -> TelemetryConsentInfo { + TelemetryConsentInfo { + consent: read_consent(&consent_path()), + active_this_session: crate::telemetry_active_this_session(), + } +} + +/// Update the consent file. The frontend never touches the file directly; +/// this is the only write path. Takes effect on next launch -- it does not +/// retroactively start or stop reporting this session (see module docs). +#[tauri::command] +pub fn set_telemetry_consent(consent: Consent) -> Result<(), String> { + write_consent(&consent_path(), consent) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn tmp_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "agentpanel_telemetry_test_{}_{}.json", + std::process::id(), + name + )) + } + + // --- read_consent: file read/parse behavior ----------------------- + + #[test] + fn missing_file_reads_as_unset() { + let path = tmp_path("missing"); + let _ = fs::remove_file(&path); + assert_eq!(read_consent(&path), Consent::Unset); + } + + #[test] + fn corrupt_json_reads_as_unset() { + let path = tmp_path("corrupt"); + fs::write(&path, "{ this is not valid json").unwrap(); + assert_eq!(read_consent(&path), Consent::Unset); + let _ = fs::remove_file(&path); + } + + #[test] + fn unrecognized_consent_value_reads_as_unset() { + let path = tmp_path("badvalue"); + fs::write(&path, r#"{"consent": "maybe"}"#).unwrap(); + assert_eq!(read_consent(&path), Consent::Unset); + let _ = fs::remove_file(&path); + } + + #[test] + fn explicit_unset_value_reads_as_unset() { + let path = tmp_path("explicit_unset"); + fs::write(&path, r#"{"consent": "unset"}"#).unwrap(); + assert_eq!(read_consent(&path), Consent::Unset); + let _ = fs::remove_file(&path); + } + + // --- write_consent / read_consent round trip ----------------------- + + #[test] + fn round_trip_granted() { + let path = tmp_path("granted"); + write_consent(&path, Consent::Granted).unwrap(); + assert_eq!(read_consent(&path), Consent::Granted); + let _ = fs::remove_file(&path); + } + + #[test] + fn round_trip_denied() { + let path = tmp_path("denied"); + write_consent(&path, Consent::Denied).unwrap(); + assert_eq!(read_consent(&path), Consent::Denied); + let _ = fs::remove_file(&path); + } + + #[test] + fn round_trip_unset() { + let path = tmp_path("unset_roundtrip"); + write_consent(&path, Consent::Unset).unwrap(); + assert_eq!(read_consent(&path), Consent::Unset); + let _ = fs::remove_file(&path); + } + + #[test] + fn write_then_overwrite_reflects_latest_value() { + let path = tmp_path("overwrite"); + write_consent(&path, Consent::Granted).unwrap(); + assert_eq!(read_consent(&path), Consent::Granted); + write_consent(&path, Consent::Denied).unwrap(); + assert_eq!(read_consent(&path), Consent::Denied); + let _ = fs::remove_file(&path); + } + + // --- scrub_paths: home-directory / username redaction --------------- + // + // The spec does not pin down the exact replacement token (e.g. "~", + // "", or something else) used when a path is scrubbed -- only that + // the username/home directory must not survive, and that messages are + // "kept but path-scrubbed" (not dropped). These tests assert that + // invariant rather than an exact scrubbed string, since asserting a + // specific token would be guessing at an unstated resolution. + + #[test] + fn scrub_paths_redacts_macos_username() { + let input = "Error at /Users/jane/dev/agentpanel/src-tauri/src/main.rs:42"; + let out = scrub_paths(input); + assert!(!out.contains("jane"), "username should be redacted: {out}"); + assert!(!out.contains("/Users/jane"), "home dir should be redacted: {out}"); + } + + #[test] + fn scrub_paths_redacts_windows_username() { + let input = r"Error at C:\Users\jane\dev\agentpanel\src-tauri\src\main.rs:42"; + let out = scrub_paths(input); + assert!(!out.contains("jane"), "username should be redacted: {out}"); + assert!( + !out.contains(r"C:\Users\jane"), + "home dir should be redacted: {out}" + ); + } + + #[test] + fn scrub_paths_keeps_message_text_intact() { + let input = "panic: index out of bounds for /Users/jane/repo/src/lib.rs"; + let out = scrub_paths(input); + assert!( + out.contains("panic: index out of bounds"), + "non-path message text should be preserved: {out}" + ); + assert!(!out.contains("jane")); + } + + // --- scrub_paths: additional home/mount prefixes (fix round h) ------ + + #[test] + fn scrub_paths_redacts_linux_home_username() { + let input = "Error at /home/jane/dev/agentpanel/src/lib.rs:10"; + let out = scrub_paths(input); + assert!(!out.contains("jane"), "username should be redacted: {out}"); + assert!(!out.contains("/home/jane"), "home dir should be redacted: {out}"); + } + + #[test] + fn scrub_paths_redacts_root_home() { + let input = "Error at /root/repo/src/lib.rs:10"; + let out = scrub_paths(input); + assert!(!out.contains("/root"), "root home dir should be redacted: {out}"); + } + + #[test] + fn scrub_paths_redacts_macos_volume_mount() { + let input = "Error at /Volumes/Untitled/dev/agentpanel/src/lib.rs:10"; + let out = scrub_paths(input); + assert!(!out.contains("Untitled"), "volume name should be redacted: {out}"); + assert!(!out.contains("/Volumes/Untitled"), "mount prefix should be redacted: {out}"); + } + + #[test] + fn scrub_paths_redacts_unc_share() { + let input = r"Error at \\build-server\repos\agentpanel\src\lib.rs:10"; + let out = scrub_paths(input); + assert!(!out.contains("build-server"), "server name should be redacted: {out}"); + assert!(!out.contains("repos"), "share name should be redacted: {out}"); + } + + #[test] + fn scrub_paths_redacts_lowercase_drive_forward_slash_windows_path() { + // Lowercase drive letter + forward-slash separator (Git-Bash-style + // paths); regression coverage for the widened truncation backstop, + // which previously only matched uppercase `[A-Z]:\Users\`. + let input = "Error at c:/Users/jane/dev/agentpanel/src/lib.rs:10"; + let out = scrub_paths(input); + assert!(!out.contains("jane"), "username should be redacted: {out}"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3870fbc..ff7bb40 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,6 +7,7 @@ "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "npm run build", + "beforeBundleCommand": "node scripts/strip-sourcemaps.mjs", "frontendDist": "../dist" }, "app": { diff --git a/src/App.tsx b/src/App.tsx index b663bc9..61a2192 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { ConfirmDialog } from "./components/ConfirmDialog"; import { PaneErrorBoundary } from "./components/PaneErrorBoundary"; import { NotesPanel } from "./components/NotesPanel"; import { UpdateBanner } from "./components/UpdateBanner"; +import { TelemetryBanner } from "./components/TelemetryBanner"; import { useStore, worktreeLabels } from "./state/store"; import { snapshotStates } from "./state/agentRuntime"; import type { AgentState } from "./state/activity"; @@ -16,6 +17,7 @@ import { applyTheme, schemeBySlug } from "./themes/apply"; import { runBenchmark } from "./lib/bench"; import { notify } from "./lib/notify"; import { runUpdateCheck } from "./lib/updater"; +import { initTelemetry } from "./lib/telemetry"; import "./App.css"; /** Shallow-equal two paneId->state maps, to skip no-op ticker updates. */ @@ -111,6 +113,13 @@ function App() { void loadRepositories(); }, [loadRepositories]); + // Crash reporting: only starts sending if the Rust SDK actually initialized + // this session (active_this_session) -- gated on startup consent, not the + // live file. No-op (never throws) when telemetry is off. + useEffect(() => { + void initTelemetry(); + }, []); + // Recompute live agent states (running/idle/awaiting/exited) once a second and // push to the store only when something changed. Runs even while hidden, so a // background agent finishing/needing input is still detected (for notifications). @@ -298,6 +307,7 @@ function App() {
+ {paletteOpen && ( diff --git a/src/components/PaneErrorBoundary.tsx b/src/components/PaneErrorBoundary.tsx index 8cf664e..8cfa755 100644 --- a/src/components/PaneErrorBoundary.tsx +++ b/src/components/PaneErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, type ReactNode } from "react"; +import { captureError } from "../lib/telemetry"; /** * Per-pane error boundary (issue #18). Without one, a render/effect crash in a @@ -14,6 +15,14 @@ export class PaneErrorBoundary extends Component<{ children: ReactNode }, { erro return { error }; } + componentDidCatch(error: Error) { + // No-op unless telemetry.ts's initTelemetry() has loaded the SDK (gated on + // consent). No extra context (e.g. component stack) is attached -- that's + // outside the spec's report-content allowlist. Does not import + // @sentry/browser directly, so a never-consented user never bundles it. + captureError(error); + } + render() { const { error } = this.state; if (error) { diff --git a/src/components/SettingsModal.telemetry.test.tsx b/src/components/SettingsModal.telemetry.test.tsx new file mode 100644 index 0000000..5aed7e9 --- /dev/null +++ b/src/components/SettingsModal.telemetry.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { SettingsModal } from "./SettingsModal"; +import { invoke } from "@tauri-apps/api/core"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn().mockResolvedValue("0.4.0") })); +vi.mock("../lib/updater", () => ({ runUpdateCheck: vi.fn().mockResolvedValue(undefined) })); + +afterEach(cleanup); + +type ConsentValue = "unset" | "granted" | "denied"; + +function mockConsent(consent: ConsentValue) { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === "get_telemetry_consent") { + return Promise.resolve({ consent, active_this_session: consent === "granted" }); + } + if (cmd === "set_telemetry_consent") return Promise.resolve(undefined); + if (cmd === "list_shells" || cmd === "list_fonts") return Promise.resolve([]); + return Promise.resolve(undefined); + }); +} + +function telemetryCheckbox() { + return screen.getByRole("checkbox", { name: /send anonymous crash reports/i }) as HTMLInputElement; +} + +describe("SettingsModal telemetry toggle", () => { + it("reflects granted consent as checked", async () => { + mockConsent("granted"); + render(); + await waitFor(() => expect(telemetryCheckbox().checked).toBe(true)); + }); + + it("reflects denied consent as unchecked", async () => { + mockConsent("denied"); + render(); + await waitFor(() => expect(telemetryCheckbox()).toBeTruthy()); + expect(telemetryCheckbox().checked).toBe(false); + }); + + it("reflects unset consent as unchecked", async () => { + mockConsent("unset"); + render(); + await waitFor(() => expect(telemetryCheckbox()).toBeTruthy()); + expect(telemetryCheckbox().checked).toBe(false); + }); + + it("shows helper text that the change takes effect after restart", async () => { + mockConsent("unset"); + render(); + await waitFor(() => expect(screen.getByText(/takes effect after restart/i)).toBeTruthy()); + }); + + it("checking the box calls set_telemetry_consent with granted", async () => { + mockConsent("unset"); + render(); + await waitFor(() => expect(telemetryCheckbox()).toBeTruthy()); + fireEvent.click(telemetryCheckbox()); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("set_telemetry_consent", { consent: "granted" }), + ); + }); + + it("unchecking an already-granted box calls set_telemetry_consent with denied", async () => { + mockConsent("granted"); + render(); + await waitFor(() => expect(telemetryCheckbox().checked).toBe(true)); + fireEvent.click(telemetryCheckbox()); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("set_telemetry_consent", { consent: "denied" }), + ); + }); +}); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index d7b0e5b..4f44d3b 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -4,6 +4,7 @@ import { getVersion } from "@tauri-apps/api/app"; import { useStore } from "../state/store"; import { useEscapeToClose } from "../lib/useEscapeToClose"; import { runUpdateCheck } from "../lib/updater"; +import { getTelemetryConsent, setTelemetryConsent } from "../lib/telemetry"; import { SCHEMES } from "../themes/schemes"; import type { ShellInfo } from "../types"; @@ -24,6 +25,7 @@ function shellMatches(detectedPath: string, saved: string): boolean { export function SettingsModal({ onClose }: { onClose: () => void }) { const settings = useStore((s) => s.settings); const updateSettings = useStore((s) => s.updateSettings); + const pushToast = useStore((s) => s.pushToast); const [shell, setShell] = useState(settings.shell); const [terminalEnv, setTerminalEnv] = useState(settings.terminalEnv); @@ -35,6 +37,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) { const [editor, setEditor] = useState(settings.editorCommand); const [pathImportHint, setPathImportHint] = useState(""); const [appVersion, setAppVersion] = useState(""); + const [crashReports, setCrashReports] = useState(false); // Escape = close without saving (same as Cancel / clicking the backdrop). useEscapeToClose(onClose); @@ -73,6 +76,32 @@ export function SettingsModal({ onClose }: { onClose: () => void }) { }; }, []); + // Reflect the Rust-owned consent file (source of truth; not localStorage). + useEffect(() => { + let alive = true; + void getTelemetryConsent() + .then((info) => { + if (alive) setCrashReports(info.consent === "granted"); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const onCrashReportsChange = async (checked: boolean) => { + const prev = crashReports; + setCrashReports(checked); + try { + await setTelemetryConsent(checked ? "granted" : "denied"); + } catch (err) { + // Write failure: surface as a toast, leave consent (and the checkbox) + // as it was rather than showing a value that didn't actually persist. + setCrashReports(prev); + pushToast(`Couldn't update crash-report setting: ${err}`, "error"); + } + }; + const onPickShell = (value: string) => { if (value === CUSTOM) { setCustomShell(true); @@ -324,6 +353,18 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
+
+ + Takes effect after restart. +
+
Keyboard shortcuts
    diff --git a/src/components/TelemetryBanner.test.tsx b/src/components/TelemetryBanner.test.tsx new file mode 100644 index 0000000..32ff66f --- /dev/null +++ b/src/components/TelemetryBanner.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { TelemetryBanner } from "./TelemetryBanner"; +import { useStore } from "../state/store"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +afterEach(cleanup); + +type ConsentValue = "unset" | "granted" | "denied"; + +function mockConsent(consent: ConsentValue, activeThisSession = false) { + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === "get_telemetry_consent") { + return Promise.resolve({ consent, active_this_session: activeThisSession }); + } + if (cmd === "set_telemetry_consent") { + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); +} + +describe("TelemetryBanner", () => { + it("renders the crash-report prompt when consent is unset", async () => { + mockConsent("unset"); + render(); + await waitFor(() => expect(screen.getByText(/anonymous crash reports/i)).toBeTruthy()); + expect(screen.getByRole("button", { name: /^yes$/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^no$/i })).toBeTruthy(); + }); + + it("renders nothing when consent is already granted", async () => { + mockConsent("granted"); + const { container } = render(); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("get_telemetry_consent")); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when consent is already denied", async () => { + mockConsent("denied"); + const { container } = render(); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("get_telemetry_consent")); + expect(container.firstChild).toBeNull(); + }); + + it("Yes grants consent and dismisses the banner permanently", async () => { + mockConsent("unset"); + render(); + const yesButton = await screen.findByRole("button", { name: /^yes$/i }); + fireEvent.click(yesButton); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("set_telemetry_consent", { consent: "granted" }), + ); + expect(screen.queryByText(/anonymous crash reports/i)).toBeNull(); + }); + + it("No denies consent and dismisses the banner permanently", async () => { + mockConsent("unset"); + render(); + const noButton = await screen.findByRole("button", { name: /^no$/i }); + fireEvent.click(noButton); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("set_telemetry_consent", { consent: "denied" }), + ); + expect(screen.queryByText(/anonymous crash reports/i)).toBeNull(); + }); + + it("reverts and toasts when set_telemetry_consent rejects", async () => { + mockConsent("unset"); + vi.mocked(invoke).mockImplementation((cmd: string) => { + if (cmd === "get_telemetry_consent") { + return Promise.resolve({ consent: "unset", active_this_session: false }); + } + if (cmd === "set_telemetry_consent") { + return Promise.reject(new Error("disk full")); + } + return Promise.resolve(undefined); + }); + useStore.setState({ toasts: [] }); + render(); + const yesButton = await screen.findByRole("button", { name: /^yes$/i }); + fireEvent.click(yesButton); + + // Banner stays up (consent reverted to "unset") and a toast is pushed -- + // same pattern as SettingsModal.tsx's onCrashReportsChange. + await waitFor(() => expect(useStore.getState().toasts.length).toBeGreaterThan(0)); + expect(screen.getByText(/anonymous crash reports/i)).toBeTruthy(); + }); +}); diff --git a/src/components/TelemetryBanner.tsx b/src/components/TelemetryBanner.tsx new file mode 100644 index 0000000..2c91358 --- /dev/null +++ b/src/components/TelemetryBanner.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { getTelemetryConsent, setTelemetryConsent, type ConsentValue } from "../lib/telemetry"; +import { useStore } from "../state/store"; + +/** + * First-run crash-reporting prompt (docs/superpowers/specs/2026-07-23-crash-reporting-design.md). + * Shown only while consent is `unset`; either choice calls `set_telemetry_consent` + * and the banner never reappears (backed by the Rust-owned consent file, not + * localStorage -- so a corrupt/unreadable file also reads as `unset` and this + * banner reappears, per the design doc's error-handling section). + * + * Same non-blocking bar pattern/placement as `UpdateBanner.tsx`. + */ +export function TelemetryBanner() { + const [consent, setConsent] = useState(null); + const pushToast = useStore((s) => s.pushToast); + + useEffect(() => { + let alive = true; + void getTelemetryConsent() + .then((info) => { + if (alive) setConsent(info.consent); + }) + .catch(() => { + if (alive) setConsent("unset"); + }); + return () => { + alive = false; + }; + }, []); + + if (consent !== "unset") return null; + + const choose = (value: "granted" | "denied") => { + setConsent(value); + setTelemetryConsent(value).catch((err) => { + // Write failed: revert so the banner stays up rather than silently + // claiming a choice was recorded when it wasn't (same pattern as + // SettingsModal.tsx's onCrashReportsChange). + setConsent("unset"); + pushToast(`Couldn't save crash-report preference: ${err}`, "error"); + }); + }; + + return ( +
    + + Help improve AgentPanel — send anonymous crash reports?{" "} + + Learn what's collected. + + + + +
    + ); +} diff --git a/src/lib/telemetry.test.ts b/src/lib/telemetry.test.ts new file mode 100644 index 0000000..77c31e4 --- /dev/null +++ b/src/lib/telemetry.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { scrubPaths } from "./telemetry"; + +// The spec does not pin down the exact replacement token used when a path is +// scrubbed (e.g. "~", "", or something else) -- only that the +// username/home directory must not survive, and that messages are "kept but +// path-scrubbed" (not dropped). These tests assert that invariant rather than +// an exact scrubbed string, since asserting a specific token would be +// guessing at an unstated resolution. + +describe("scrubPaths", () => { + it("redacts a macOS home-directory username", () => { + const input = "Error at /Users/jane/dev/agentpanel/src/lib/telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("jane"); + expect(out).not.toContain("/Users/jane"); + }); + + it("redacts a Windows home-directory username", () => { + const input = "Error at C:\\Users\\jane\\dev\\agentpanel\\src\\lib\\telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("jane"); + expect(out).not.toContain("C:\\Users\\jane"); + }); + + it("keeps non-path message text intact", () => { + const input = "TypeError: cannot read properties of undefined at /Users/jane/repo/src/App.tsx"; + const out = scrubPaths(input); + expect(out).toContain("TypeError: cannot read properties of undefined"); + expect(out).not.toContain("jane"); + }); + + // --- additional home/mount prefixes (fix round h) --------------------- + + it("redacts a Linux home-directory username", () => { + const input = "Error at /home/jane/dev/agentpanel/src/lib/telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("jane"); + expect(out).not.toContain("/home/jane"); + }); + + it("redacts the Linux root home directory", () => { + const input = "Error at /root/repo/src/lib/telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("/root"); + }); + + it("redacts a macOS volume mount", () => { + const input = "Error at /Volumes/Untitled/dev/agentpanel/src/lib/telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("Untitled"); + expect(out).not.toContain("/Volumes/Untitled"); + }); + + it("redacts a Windows UNC share", () => { + const input = "Error at \\\\build-server\\repos\\agentpanel\\src\\lib\\telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("build-server"); + expect(out).not.toContain("repos"); + }); + + it("redacts a lowercase-drive, forward-slash Windows path", () => { + // Regression coverage for the widened truncation backstop, which + // previously only matched uppercase `[A-Z]:\Users\`. + const input = "Error at c:/Users/jane/dev/agentpanel/src/lib/telemetry.ts:10"; + const out = scrubPaths(input); + expect(out).not.toContain("jane"); + }); +}); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..b8f000e --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,183 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getVersion } from "@tauri-apps/api/app"; +import type * as Sentry from "@sentry/browser"; + +/** + * Opt-in crash reporting (docs/superpowers/specs/2026-07-23-crash-reporting-design.md). + * Consent is Rust-owned (see src-tauri/src/telemetry.rs) -- the frontend never + * touches the consent file directly and does not mirror it into localStorage. + */ + +export type ConsentValue = "unset" | "granted" | "denied"; + +export interface TelemetryConsentInfo { + consent: ConsentValue; + /** Captured at Rust startup: whether the Rust SDK actually initialized this + * session. Gates the JS SDK too, so a mid-session grant can't start + * reporting while the Rust SDK stays inactive (design doc, "Frontend side"). */ + active_this_session: boolean; +} + +export async function getTelemetryConsent(): Promise { + return invoke("get_telemetry_consent"); +} + +export async function setTelemetryConsent(consent: ConsentValue): Promise { + await invoke("set_telemetry_consent", { consent }); +} + +// --- path scrubbing ----------------------------------------------------- +// +// Mirrors src-tauri/src/telemetry.rs::scrub_paths -- keep both in sync. The +// spec does not pin down the exact replacement token (here "~"), only that +// the username/home directory must not survive and that messages are "kept +// but path-scrubbed" (not dropped). + +const HOME_PATH_RE = + /(?:\/Users\/|\/home\/|\/Volumes\/|\/root\b|[A-Za-z]:[\\/]Users[\\/]|\\\\[^\\/\s]+\\[^\\/\s]+)[^\s]*/g; +const UNSCRUBBED_MARKER_RE = + /(?:\/Users\/|\/home\/|\/Volumes\/|\/root\b|[A-Za-z]:[\\/]Users[\\/]|\\\\[^\\/\s]+\\[^\\/\s]+)/; +const PROJECT_MARKERS = new Set(["agentpanel", "AgentPanel", "src-tauri", "src"]); + +/** How many leading segments (already split on the token's separator) make up + * the home/mount prefix to fold into "~". Mirrors telemetry.rs's + * `home_prefix_fold_len`. */ +function homePrefixFoldLen(segments: string[]): number | null { + const idx = segments.findIndex((s) => s === "Users" || s === "home" || s === "Volumes"); + if (idx !== -1) return Math.min(idx + 2, segments.length); + + const rootIdx = segments.indexOf("root"); + if (rootIdx !== -1) return rootIdx + 1; + + if (segments.length >= 4 && segments[0] === "" && segments[1] === "") { + // Leading "\\server\share" splits (on "\\") into ["", "", server, share, ...]. + return 4; + } + return null; +} + +function redactPathToken(token: string): string { + const sep = token.includes("\\") ? "\\" : "/"; + const segments = token.split(sep); + + const markerIdx = segments.findIndex((s) => PROJECT_MARKERS.has(s)); + if (markerIdx !== -1) { + return segments.slice(markerIdx).join(sep); + } + + const foldLen = homePrefixFoldLen(segments); + if (foldLen !== null) { + const rest = segments.slice(foldLen); + return rest.length === 0 ? "~" : `~${sep}${rest.join(sep)}`; + } + + return token; +} + +/** + * Redact home-directory/mount-point segments from Windows-, macOS- and + * Linux-style absolute paths appearing anywhere in `input`. Non-path text is + * left intact. If, after redaction, one of the recognized prefixes still + * survives (i.e. the text can't be scrubbed with confidence), it is + * truncated at that point rather than sent with a username/host attached. + */ +export function scrubPaths(input: string): string { + const out = input.replace(HOME_PATH_RE, redactPathToken); + const m = out.match(UNSCRUBBED_MARKER_RE); + return m && m.index !== undefined ? out.slice(0, m.index) : out; +} + +function scrubEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent { + if (event.message) event.message = scrubPaths(event.message); + for (const exception of event.exception?.values ?? []) { + if (exception.value) exception.value = scrubPaths(exception.value); + for (const frame of exception.stacktrace?.frames ?? []) { + if (frame.filename) frame.filename = scrubPaths(frame.filename); + } + } + // Defense-in-depth: the Breadcrumbs integration is dropped entirely below + // (no automatic breadcrumb source runs), but scrub any that show up anyway + // (e.g. a future manually-added breadcrumb) rather than assume none ever will. + for (const crumb of event.breadcrumbs ?? []) { + if (crumb.message) crumb.message = scrubPaths(crumb.message); + if (crumb.data) { + for (const key of Object.keys(crumb.data)) { + const value = crumb.data[key]; + if (typeof value === "string") crumb.data[key] = scrubPaths(value); + } + } + } + return event; +} + +let sdk: typeof Sentry | null = null; +let initPromise: Promise | null = null; + +/** + * Initialize the JS Sentry SDK, gated on the Rust SDK actually having + * initialized this session (`active_this_session`) -- never on the raw + * consent file value (see `TelemetryConsentInfo`). Envelopes are handed to + * the `sentry-tauri` plugin transport, which routes them to the Rust SDK + * over Tauri IPC; nothing is sent directly from the WebView. + * + * `@sentry/browser` and the plugin's JS helpers are loaded via dynamic + * `import()` only once `active_this_session` is confirmed -- a + * never-consented user never pays to bundle/parse them. + * + * Never throws -- telemetry failing to start must not block the app. + */ +export async function initTelemetry(): Promise { + if (initPromise) return initPromise; + initPromise = (async () => { + let info: TelemetryConsentInfo; + try { + info = await getTelemetryConsent(); + } catch { + return; + } + if (!info.active_this_session) return; + + const [SentryModule, pluginApi] = await Promise.all([ + import("@sentry/browser"), + import("tauri-plugin-sentry-api"), + ]); + sdk = SentryModule; + + let release: string | undefined; + try { + release = `agentpanel@${await getVersion()}`; + } catch { + release = undefined; + } + + sdk.init({ + ...pluginApi.defaultOptions, + release, + // Every automatic breadcrumb source (DOM clicks, console, fetch/xhr, + // history navigation, Sentry's own internal breadcrumbs) is dropped + // outright -- the spec allowlist is "exception type/message/stack, app + // version, OS/arch" only. DOM breadcrumbs in particular serialize + // element title/aria-label attributes, and this app's TabBar/Sidebar + // put absolute cwd/repo paths, commit messages and command lines in + // those attributes. + integrations: (integrations) => + integrations.filter((i) => i.name !== "BrowserSession" && i.name !== "Breadcrumbs"), + beforeBreadcrumb: undefined, + beforeSend: scrubEvent, + }); + })(); + return initPromise; +} + +/** + * Reports an error to Sentry if (and only if) the SDK has been loaded (see + * `initTelemetry`) -- a silent no-op otherwise, so callers (e.g. + * `PaneErrorBoundary`) never need to know whether telemetry is active. + * Deliberately takes no extra context (e.g. a component stack): that's + * outside the spec allowlist ("exception type/message/stack, version, + * OS/arch" only), so there's nothing to scrub-or-drop -- it's just never + * attached. + */ +export function captureError(error: Error): void { + sdk?.captureException(error); +} diff --git a/vite.config.ts b/vite.config.ts index ddad22a..d18cecd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,14 @@ const host = process.env.TAURI_DEV_HOST; export default defineConfig(async () => ({ plugins: [react()], + build: { + // Crash reporting (docs/superpowers/specs/2026-07-23-crash-reporting-design.md): + // "hidden" emits .map files (for the release workflow's Sentry upload + // step) without adding //# sourceMappingURL comments to the shipped JS, + // so the maps aren't fetchable/linked from the installed app. + sourcemap: "hidden", + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent Vite from obscuring rust errors