diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b96e2d0033 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +blank_issues_enabled: false + +contact_links: + - name: Questions & Answers + url: https://github.com/nesszer/Win-CodexBar/discussions + about: Ask usage questions, share tips, and discuss Win-CodexBar with other users. + - name: Configuration docs + url: https://github.com/nesszer/Win-CodexBar/blob/main/docs/CONFIGURATION.md + about: Configuration questions are best answered by the docs first. + - name: Cookies and browser docs + url: https://github.com/nesszer/Win-CodexBar/blob/main/docs/COOKIES.md + about: Browser cookie import questions are covered in the cookies docs. diff --git a/README.md b/README.md index 02c645d1cb..73d926fb28 100755 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Install with Windows Package Manager: winget install Finesssee.Win-CodexBar ``` -Or download the latest installer/portable build from [GitHub Releases](https://github.com/Finesssee/Win-CodexBar/releases). +Or download the latest installer/portable build from [GitHub Releases](https://github.com/nesszer/Win-CodexBar/releases). - Installer: `CodexBar--Setup.exe` - Portable: `CodexBar--portable.exe` @@ -135,7 +135,7 @@ The UI and contributor reporting currently support: ```powershell # Prerequisites: Node.js + pnpm. Rust and MinGW are installed by the script when needed. -git clone https://github.com/Finesssee/Win-CodexBar.git +git clone https://github.com/nesszer/Win-CodexBar.git cd Win-CodexBar .\scripts\dev.ps1 ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..47e9f5e2bd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security policy + +## Supported versions + +Only the latest release of Win-CodexBar is supported. Security fixes are made +against the current release line; older releases and the historical upstream +macOS project are not patched. + +## Reporting a vulnerability + +Please use GitHub's private vulnerability reporting: open the +[Security tab](https://github.com/nesszer/Win-CodexBar/security) and click +"Report a vulnerability". + +Do **not** open a public GitHub issue for a vulnerability. Public issues and +the bug report template are for non-security problems only. + +Win-CodexBar handles provider cookies, OAuth tokens, and API keys locally, so +reports touching that surface — credential extraction, storage, redaction, or +leakage — are taken seriously. Please keep report details private and do not +paste secrets, cookies, or tokens into any report. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000000..253e30632b --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,24 @@ +# Support + +## Bug reports + +Use the [bug report template](https://github.com/nesszer/Win-CodexBar/issues/new?template=bug_report.yml). +If you have safe diagnostics from the CLI, attach them: +`codexbar diagnose` exports provider diagnostics as JSON with no cookies or +tokens. + +## Questions and discussions + +Ask in [Discussions](https://github.com/nesszer/Win-CodexBar/discussions). + +## Docs + +Configuration, CLI, cookies, providers, and privacy docs live in +[`docs/`](docs): ARCHITECTURE, BUILDING, CLI, CODE_SIGNING, CONFIGURATION, +COOKIES, PRIVACY, PROVIDERS, WINDOWS_PROOF, WSL, and the ADRs under +[`docs/adr/`](docs/adr). + +Feature requests use the +[feature request template](https://github.com/nesszer/Win-CodexBar/issues/new?template=feature_request.yml). +Security vulnerabilities go through the +[Security tab](SECURITY.md), not public issues. diff --git a/apps/desktop-tauri/src-tauri/src/commands/diagnostics.rs b/apps/desktop-tauri/src-tauri/src/commands/diagnostics.rs index 35b5f1c8d3..087f64a55e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/diagnostics.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/diagnostics.rs @@ -1,5 +1,53 @@ -// `get_safe_diagnostics` (the only Tauri command this module exposed) was -// removed as orphaned dead code — the frontend wrappers that invoked it were -// deleted, leaving zero invokers. Its `SafeDiagnostics` payload + the -// `safe_diagnostics_from` builder + the secret-redaction test existed solely -// to support that command and were removed with it. +//! `get_safe_diagnostics` — a copy-friendly, secret-free diagnostics string +//! for bug reports. Contains only: app version/build, OS, update channel, +//! the log file's config-relative path, and the redacted log tail. The log +//! path is trimmed to its segments below the user profile so no username is +//! embedded, and the tail is redacted for both secrets and email addresses. +//! Never includes provider names, plans, account info, cookies, or tokens. + +use super::*; + +/// Last segments of `path` below the user profile (never the profile itself). +fn config_relative_path(path: &std::path::Path) -> String { + let segments: Vec = + path.iter().map(std::borrow::ToOwned::to_owned).collect(); + let tail: Vec = segments + .iter() + .rev() + .take(3) + .rev() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + if tail.len() == 3 { + tail.join("/") + } else { + "unresolvable".to_string() + } +} + +#[tauri::command] +pub fn get_safe_diagnostics() -> String { + let settings = Settings::load(); + let log_dir = codexbar::logging::log_file_path() + .map(|p| config_relative_path(&p)) + .unwrap_or_else(|| "unresolvable".to_string()); + let log_tail = + codexbar::logging::read_log_tail().unwrap_or_else(|| "log file unavailable".to_string()); + + format!( + "CodexBar diagnostics\n\ + -------------------\n\ + version: {} (build {})\n\ + os: {}\n\ + channel: {}\n\ + log file: {}\n\ + --- last log lines (redacted) ---\n\ + {}", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_NUMBER").unwrap_or("dev"), + std::env::consts::OS, + update_channel_label(settings.update_channel), + log_dir, + log_tail, + ) +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index e0d6f4bd41..cd5420ef51 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -36,6 +36,7 @@ mod codex_accounts; mod codex_workspaces; mod credential_detection; mod credentials; +mod diagnostics; mod locale_cmd; mod provider_detail; mod provider_settings; @@ -52,6 +53,7 @@ pub use codex_accounts::*; pub use codex_workspaces::*; pub use credential_detection::*; pub use credentials::*; +pub use diagnostics::*; pub use locale_cmd::*; pub use provider_detail::*; pub use provider_settings::*; diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 2b6d77fab0..e7e1cfebda 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -110,6 +110,11 @@ fn should_suppress_blur_dismiss(launch: LaunchBehavior, proof_mode: bool) -> boo } fn main() { + // Per-process log file names: the shell writes codexbar-desktop.log so + // its cached handle never blocks the CLI's rotation on Windows. + // SAFETY: runs before any thread spawns; no concurrent env access exists. + unsafe { std::env::set_var("CODEXBAR_PROCESS", "desktop") }; + codexbar::logging::install_panic_hook(); codexbar::logging::init(false, false).expect("failed to initialize logging"); let proof_config = proof_harness::ProofConfig::from_env(); @@ -200,6 +205,7 @@ fn main() { commands::remove_token_account, commands::set_active_token_account, commands::get_app_info, + commands::get_safe_diagnostics, commands::get_provider_chart_data, commands::get_provider_local_usage_summary, commands::get_usage_spend_summary, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index fc5dcb8a22..63ff983b4b 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -764,6 +764,10 @@ export const ALL_LOCALE_KEYS = [ "AboutLinkGitHub", "AboutLinkWebsite", "AboutLinkOriginalProject", + "DiagnosticsSectionHeading", + "DiagnosticsCopyButton", + "DiagnosticsCopied", + "DiagnosticsCopyFailed", // Tauri desktop shell — Cookies tab hints / placeholder "SavedCookiesHint", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 51f3ca09ff..d255a3815e 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -528,3 +528,7 @@ export function codexAccountRestartDesktop( export function getCodexAccountsState(): Promise { return invoke("get_codex_accounts_state"); } + +export function getSafeDiagnostics(): Promise { + return invoke("get_safe_diagnostics"); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 9837634fde..5c9b8dcbdc 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -122,10 +122,11 @@ describe("AboutTab", () => { fireEvent.click(await screen.findByRole("button", { name: "AboutLinkGitHub" })); fireEvent.click(screen.getByRole("button", { name: "AboutLinkWebsite" })); fireEvent.click(screen.getByRole("button", { name: "AboutLinkOriginalProject" })); + fireEvent.click(screen.getByRole("button", { name: "SubmitIssue" })); expect(tauriMocks.openExternalUrl).toHaveBeenNthCalledWith( 1, - "https://github.com/Finesssee/Win-CodexBar", + "https://github.com/nesszer/Win-CodexBar", ); expect(tauriMocks.openExternalUrl).toHaveBeenNthCalledWith( 2, @@ -135,6 +136,10 @@ describe("AboutTab", () => { 3, "https://github.com/steipete/CodexBar", ); + expect(tauriMocks.openExternalUrl).toHaveBeenNthCalledWith( + 4, + "https://github.com/nesszer/Win-CodexBar/issues/new?labels=bug&template=bug_report.yml", + ); }); it("shows a link error if the OS browser launch fails", async () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx index 1b971fe801..c6e62e429c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx @@ -8,10 +8,13 @@ import type { LocaleKey } from "../../../i18n/keys"; import type { TabProps } from "../settingsTabs"; import codexbarIcon from "../../../assets/codexbar-icon.png"; +const REPO_URL = "https://github.com/nesszer/Win-CodexBar"; +const SUBMIT_ISSUE_URL = `${REPO_URL}/issues/new?labels=bug&template=bug_report.yml`; + const ABOUT_LINKS: ReadonlyArray<{ labelKey: LocaleKey; url: string }> = [ { labelKey: "AboutLinkGitHub", - url: "https://github.com/Finesssee/Win-CodexBar", + url: REPO_URL, }, { labelKey: "AboutLinkWebsite", @@ -90,6 +93,13 @@ export default function AboutTab({ settings, set, saving }: TabProps) { ))} + {linkError && (

{t("ErrorPrefix")} {linkError} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx new file mode 100644 index 0000000000..e3ac4dd2a6 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getSafeDiagnostics: vi.fn(), + registerGlobalShortcut: vi.fn().mockResolvedValue(undefined), + unregisterGlobalShortcut: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../lib/tauri", () => tauriMocks); +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); + +import AdvancedTab from "./AdvancedTab"; +import type { SettingsSnapshot } from "../../../types/bridge"; + +const settings: SettingsSnapshot = { + enabledProviders: [], + refreshIntervalSecs: 300, + adaptiveRefresh: false, + refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, + startAtLogin: false, + startMinimized: false, + showNotifications: true, + soundEnabled: true, + notificationSoundTheme: "windows", + highUsageThreshold: 70, + criticalUsageThreshold: 90, + predictivePaceWarningEnabled: false, + trayIconMode: "single", + switcherShowsIcons: true, + menuBarShowsHighestUsage: true, + menuBarShowsPercent: true, + showAsUsed: false, + showAllTokenAccountsInMenu: true, + enableAnimations: true, + resetTimeRelative: true, + showResetWhenExhausted: false, + menuBarDisplayMode: "compact", + notificationSoundPaths: { + predictiveWarning: null, + highUsage: null, + criticalUsage: null, + exhausted: null, + statusIssue: null, + sessionDepleted: null, + sessionRestored: null, + }, + hidePersonalInfo: false, + autoDownloadUpdates: false, + installUpdatesOnQuit: false, + globalShortcut: "", + codexCustomSessionsDirs: [], + updateChannel: "stable", + uiLanguage: "english", + theme: "dark", + windowScalePercent: 125, + trayScalePercent: 100, + powertoysStatusPipeEnabled: false, + claudeAvoidKeychainPrompts: true, + codexSparkUsageVisible: true, + disableKeychainAccess: false, + providerMetrics: {}, + floatBarEnabled: false, + floatBarOpacity: 0.9, + floatBarScale: 100, + floatBarOrientation: "horizontal", + floatBarStyle: "floating", + floatBarClickThrough: false, + floatBarProviderIds: [], + floatBarDarkText: false, + floatBarShowResetInline: false, + floatBarShowCost: false, + claudeDailyRoutinesUsageVisible: true, + claudeAllowReadingClaudeCodeCredentials: false, + alibabaTokenPlanRegion: "cn", + weeklyProgressWorkDays: null, + costSummaryDisplayStyle: "compact", + providerAccentColors: {}, +}; + +describe("AdvancedTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getSafeDiagnostics.mockResolvedValue("diagnostics text"); + }); + + it("copies safe diagnostics to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + screen.getByRole("heading", { name: "DiagnosticsSectionHeading" }); + fireEvent.click( + screen.getByRole("button", { name: "DiagnosticsCopyButton" }), + ); + + await waitFor(() => { + expect(tauriMocks.getSafeDiagnostics).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("diagnostics text"); + }); + await waitFor(() => { + expect(screen.getAllByText("DiagnosticsCopied").length).toBeGreaterThan(0); + }); + }); + + it("shows an error when copying diagnostics fails", async () => { + tauriMocks.getSafeDiagnostics.mockRejectedValue(new Error("invoke failed")); + render(); + + screen.getByRole("heading", { name: "DiagnosticsSectionHeading" }); + fireEvent.click( + screen.getByRole("button", { name: "DiagnosticsCopyButton" }), + ); + await waitFor(() => { + expect( + screen.getAllByText(/DiagnosticsCopyFailed/).length, + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index ec3a96742c..bcae463719 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; import { + getSafeDiagnostics, registerGlobalShortcut, unregisterGlobalShortcut, } from "../../../lib/tauri"; @@ -26,6 +27,9 @@ function parseSshHosts(value: string): string[] { export default function AdvancedTab({ settings, set, saving }: TabProps) { const { t } = useLocale(); const [shortcutError, setShortcutError] = useState(null); + const [diagnosticsStatus, setDiagnosticsStatus] = useState( + null, + ); const [codexDirsDraft, setCodexDirsDraft] = useState(() => formatCodexSessionsDirs(settings.codexCustomSessionsDirs), ); @@ -33,6 +37,20 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { (settings.agentSessionSshHosts ?? []).join(", "), ); + const copyDiagnostics = useCallback(async () => { + try { + const text = await getSafeDiagnostics(); + await navigator.clipboard.writeText(text); + setDiagnosticsStatus(t("DiagnosticsCopied")); + } catch (error) { + setDiagnosticsStatus(`${t("DiagnosticsCopyFailed")} ${String(error)}`); + } + }, [t]); + + const commitCodexDirs = useCallback(() => { + set({ codexCustomSessionsDirs: parseCodexSessionsDirs(codexDirsDraft) }); + }, [codexDirsDraft, set]); + useEffect(() => { if (!saving) { setCodexDirsDraft(formatCodexSessionsDirs(settings.codexCustomSessionsDirs)); @@ -66,9 +84,6 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { } }, [set]); - const commitCodexDirs = useCallback(() => { - set({ codexCustomSessionsDirs: parseCodexSessionsDirs(codexDirsDraft) }); - }, [codexDirsDraft, set]); return ( <> @@ -314,6 +329,25 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { + + {/* ── Diagnostics ──────────────────────────────────────────── */} +

+

+ {t("DiagnosticsSectionHeading")} +

+
+ + {diagnosticsStatus && ( +

{diagnosticsStatus}

+ )} +
+
); } diff --git a/docs/WSL.md b/docs/WSL.md index cb30d99097..404bff542d 100644 --- a/docs/WSL.md +++ b/docs/WSL.md @@ -6,7 +6,7 @@ requires [WSLg](https://github.com/microsoft/wslg) (Windows 11, build 22000+). ## Quick Start ```bash -git clone https://github.com/Finesssee/Win-CodexBar.git +git clone https://github.com/nesszer/Win-CodexBar.git cd Win-CodexBar ./scripts/dev.sh ``` diff --git a/rust/src/host/command_runner.rs b/rust/src/host/command_runner.rs index 017c396014..0fb7d38181 100755 --- a/rust/src/host/command_runner.rs +++ b/rust/src/host/command_runner.rs @@ -646,7 +646,11 @@ mod tests { let result = runner.run("powershell.exe", None, &options).unwrap(); assert_eq!(result.exit_code, Some(1)); - assert!(result.text.contains("stdout line"), "stdout: {}", result.text); + assert!( + result.text.contains("stdout line"), + "stdout: {}", + result.text + ); assert!( result.stderr.contains("boom diagnostics"), "stderr: {}", diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 6eb2e3a40f..a643514b44 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1044,6 +1044,10 @@ locale_keys! { AboutLinkGitHub, AboutLinkWebsite, AboutLinkOriginalProject, + DiagnosticsSectionHeading, + DiagnosticsCopyButton, + DiagnosticsCopied, + DiagnosticsCopyFailed, // Tauri desktop shell — Cookies tab hints / placeholder SavedCookiesHint, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index a29757dfd3..90b2d4cc8a 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -725,6 +725,10 @@ AboutCopyrightAfter = by steipete. MIT License. AboutLinkGitHub = GitHub AboutLinkWebsite = Website AboutLinkOriginalProject = Original Project +DiagnosticsSectionHeading = Diagnostics +DiagnosticsCopyButton = Copy diagnostics +DiagnosticsCopied = Diagnostics copied to clipboard +DiagnosticsCopyFailed = Could not copy diagnostics SavedCookiesHint = Manual cookie overrides for browser-authenticated providers. These are used when automatic browser cookie extraction is unavailable. ImportFromBrowserHint = Extract cookies automatically from a signed-in browser. The browser must be installed on this machine and you must be signed in to the provider in that browser. NoBrowsersDetectedHint = No supported browsers detected on this machine, or automatic cookie extraction is unavailable (requires Windows with Chrome, Edge, Brave, or Firefox installed). Use the manual paste form below instead. diff --git a/rust/src/logging.rs b/rust/src/logging.rs index 76572a98b8..96efbfa5b1 100755 --- a/rust/src/logging.rs +++ b/rust/src/logging.rs @@ -1,5 +1,17 @@ //! Logging configuration using tracing +//! +//! Logging writes to stderr always and additionally to a size-capped file +//! under the app logs directory when that directory is writable. Any +//! filesystem error degrades to stderr-only logging; startup must never fail +//! because logs cannot be written. +//! +//! The CLI and the desktop shell write distinct per-process log files +//! (`codexbar-cli.log` / `codexbar-desktop.log`) so a cached handle in one +//! process never blocks the other process's rotation on Windows. +use std::io::Write as _; +use std::path::PathBuf; +use std::sync::LazyLock; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; /// Convert a displayable error into a frontend/log-safe message. @@ -7,7 +19,131 @@ pub fn safe_error_message(err: impl std::fmt::Display) -> String { crate::core::SecretRedactor::redact(&err.to_string()) } -/// Initialize the logging system +/// Canonical application config root that hosts the settings file and logs. +pub fn config_root() -> Option { + dirs::config_dir().map(|p| p.join("CodexBar")) +} + +/// Settings directory that hosts the app settings file (also the log root). +pub fn settings_dir() -> Option { + config_root() +} + +/// Maximum size of the current log file before rotation to the single backup file. +pub const LOG_MAX_BYTES: u64 = 1024 * 1024; + +/// Log file name stems. The CLI and the desktop shell must differ so their +/// cached handles never fight over the same file on Windows. +pub const LOG_FILE_STEM_CLI: &str = "codexbar-cli"; +pub const LOG_FILE_STEM_DESKTOP: &str = "codexbar-desktop"; + +static LOG_FILE_STEM: LazyLock<&'static str> = LazyLock::new(|| { + // The Tauri shell sets CODEXBAR_PROCESS=desktop before logging::init; + // everything else (the `codexbar` binary, tests) gets the CLI name. + if std::env::var_os("CODEXBAR_PROCESS").is_some_and(|v| v == "desktop") { + LOG_FILE_STEM_DESKTOP + } else { + LOG_FILE_STEM_CLI + } +}); + +/// Lines returned by the log-tail helper. +pub const LOG_TAIL_LINES: usize = 200; + +/// Path of the current in-app log file for this process, if a settings dir +/// is resolvable. +pub fn log_file_path() -> Option { + settings_dir().map(|p| p.join("logs").join(format!("{}.log", *LOG_FILE_STEM))) +} + +// -- Size-capped file writer ------------------------------------------------- + +struct CappedFileWriter { + inner: std::sync::Mutex>, + path: PathBuf, + max_bytes: u64, +} + +impl CappedFileWriter { + fn new(path: PathBuf, max_bytes: u64) -> Option { + // Best-effort creation; a failure here degrades to stderr-only. + let dir = path.parent()?; + if std::fs::create_dir_all(dir).is_err() { + return None; + } + Some(Self { + inner: std::sync::Mutex::new(None), + path, + max_bytes, + }) + } + + fn open_or_replace(&self) -> Option { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .ok() + } + + /// Rotates to the single backup file once the current file exceeds the cap; + /// every fs error degrades to a no-op so the tracing layer never fails on logging. + fn append(&self, line: &[u8]) { + let mut guard = match self.inner.lock() { + Ok(g) => g, + Err(_) => return, + }; + let over_cap = self.path.metadata().map(|m| m.len()).unwrap_or(0) > self.max_bytes; + if over_cap { + // Close the cached handle first: on Windows an open handle blocks + // the rename, and a handle left open after a successful rename + // would keep writing into the backup file. + drop(guard.take()); + let backup = self.path.with_extension("log.1"); + // Renaming replaces an existing destination on Windows and Unix, so + // the old backup is overwritten in one call. + if std::fs::rename(&self.path, &backup).is_err() { + // Rotation is best-effort; keep appending to the current file. + } + } + if guard.is_none() { + *guard = self.open_or_replace(); + } + if let Some(file) = guard.as_mut() { + let _ignored = file.write_all(line); + let _ignored2 = file.flush(); + } + } +} +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for &'a CappedFileWriter { + type Writer = &'a CappedFileWriter; + + fn make_writer(&'a self) -> Self::Writer { + self + } +} + +impl std::io::Write for &'_ CappedFileWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.append(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +// -- Wiring ------------------------------------------------------------------- + +static WRITER: LazyLock> = + LazyLock::new(|| log_file_path().and_then(|p| CappedFileWriter::new(p, LOG_MAX_BYTES))); + +fn file_writer() -> Option<&'static CappedFileWriter> { + WRITER.as_ref() +} + +/// Initialize the logging system for this process (default: CLI file name). pub fn init(verbose: bool, json: bool) -> anyhow::Result<()> { let filter = if verbose { EnvFilter::new("debug") @@ -15,17 +151,205 @@ pub fn init(verbose: bool, json: bool) -> anyhow::Result<()> { EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) }; - if json { - tracing_subscriber::registry() - .with(filter) - .with(fmt::layer().json().with_writer(std::io::stderr)) - .init(); + let file_writer = file_writer(); + let stderr_layer = if json { + fmt::layer().json().with_writer(std::io::stderr).boxed() } else { - tracing_subscriber::registry() + fmt::layer().with_writer(std::io::stderr).boxed() + }; + + match file_writer { + Some(w) => tracing_subscriber::registry() + .with(filter) + .with(stderr_layer) + .with(fmt::layer().with_writer(move || w)) + .init(), + None => tracing_subscriber::registry() .with(filter) - .with(fmt::layer().with_writer(std::io::stderr)) - .init(); + .with(stderr_layer) + .init(), } Ok(()) } + +/// Render one panic record from its parts and append it to the given writer +/// (no-op without one). Split out from the hook so the fallible path is +/// directly testable; the hook wraps it in catch_unwind and chains to the +/// previous hook. +fn write_panic_record( + writer: Option<&CappedFileWriter>, + payload: &str, + location: &str, + backtrace: &str, +) { + if let Some(writer) = writer { + writer.append( + safe_error_message(format!( + "panic at {location}: {payload}\nbacktrace:\n{backtrace}\n" + )) + .as_bytes(), + ); + } +} + +/// Install a panic hook that best-effort logs panics to the app log file and +/// then chains to the previous default hook. The hook itself never panics. +pub fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let hook_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let payload = if let Some(s) = info.payload().downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "panic payload of non-string type".to_string() + }; + let location = match info.location() { + Some(loc) => format!("{}:{}:{}", loc.file(), loc.line(), loc.column()), + None => "unknown location".to_string(), + }; + let backtrace = std::backtrace::Backtrace::force_capture().to_string(); + write_panic_record(file_writer(), &payload, &location, &backtrace); + })); + let _ignored_hook = hook_result; + previous(info); + })); +} + +/// Last LOG_TAIL_LINES lines of `content`, fully redacted: secrets via +/// SecretRedactor and email addresses unconditionally (tail output is pasted +/// into public bug reports regardless of the privacy setting). +pub fn log_tail_from(content: &str) -> String { + let tail: Vec<&str> = content.lines().rev().take(LOG_TAIL_LINES).collect(); + let tail: Vec<&str> = tail.into_iter().rev().collect(); + let redacted = crate::core::SecretRedactor::redact(&tail.join("\n")); + crate::core::PersonalInfoRedactor::redact_emails_in_text(Some(&redacted), true) + .unwrap_or(redacted) +} + +/// Read the last LOG_TAIL_LINES lines of the current log file, redacted. +pub fn read_log_tail() -> Option { + let path = log_file_path()?; + let content = std::fs::read_to_string(path).ok()?; + Some(log_tail_from(&content)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotation_moves_oversized_file_to_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("codexbar.log"); + std::fs::write(&path, "x".repeat(2048)).expect("seed file"); + let writer = CappedFileWriter::new(path.clone(), 1024).expect("writer"); + writer.append(b"trigger rotation\n"); + let backup = path.with_extension("log.1"); + assert!(backup.exists(), "backup should exist after rotation"); + assert_eq!(std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0), 17); + assert_eq!( + std::fs::metadata(&backup).map(|m| m.len()).unwrap_or(0), + 2048 + ); + } + + #[test] + fn rotation_keeps_small_file_in_place() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("codexbar.log"); + std::fs::write(&path, "small\n").expect("seed file"); + let writer = CappedFileWriter::new(path.clone(), 1024).expect("writer"); + writer.append(b"still small\n"); + assert!( + !path.with_extension("log.1").exists(), + "no rotation below cap" + ); + } + + #[test] + fn rotation_with_warm_handle_recreates_current_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("codexbar.log"); + // 60-byte seed + the 11-byte first line = 71 bytes, so the metadata + // check before the second append sees the file over the 64-byte cap. + std::fs::write(&path, "seed\n".repeat(12)).expect("seed file"); + let writer = CappedFileWriter::new(path.clone(), 64).expect("writer"); + // First append opens and caches the handle (warm). + writer.append(b"first line\n"); + // Blow past the cap while the handle is cached. + writer.append(&[b'x'; 128]); + let backup = path.with_extension("log.1"); + assert!( + backup.exists(), + "rotation must move the oversized file to the backup" + ); + // The current file must be recreated and contain only post-rotation + // bytes: the warm handle was dropped before the rename, so nothing + // leaks into the backup or the reopened file. + let current = std::fs::read_to_string(&path).expect("current file recreated"); + assert!( + !current.contains("seed"), + "seed bytes must have been rotated away" + ); + assert_eq!(current, "x".repeat(128)); + } + + #[test] + fn log_tail_from_returns_up_to_max_lines_with_full_redaction() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("logs").join("codexbar-cli.log"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + let total = LOG_TAIL_LINES + 50; + let body: String = (0..total).map(|i| format!("line {i}\n")).collect(); + std::fs::write(&path, body).expect("seed file"); + let content = std::fs::read_to_string(&path).expect("read"); + let tail = log_tail_from(&content); + let lines: Vec<&str> = tail.lines().collect(); + assert_eq!(lines.len(), LOG_TAIL_LINES); + assert_eq!(lines[0], format!("line {}", total - LOG_TAIL_LINES)); + assert_eq!(lines[lines.len() - 1], format!("line {}", total - 1)); + // Tail output is pasted into public bug reports, so email addresses + // are redacted regardless of the privacy setting. + let with_email = format!( + "line 0\ncontact me at user@example.com\n{}", + "x".repeat(2048) + ); + let redacted = log_tail_from(&with_email); + assert!( + !redacted.contains("user@example.com"), + "emails must be redacted: {redacted}" + ); + assert!( + redacted.contains("Hidden"), + "email placeholder expected: {redacted}" + ); + } + + #[test] + fn write_panic_record_is_noop_without_writer() { + // The hook body must never panic even with no writer available. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + write_panic_record(None, "boom", "src/main.rs:1:1", "frame 0"); + })); + assert!(result.is_ok()); + } + #[test] + fn write_panic_record_appends_to_real_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("logs").join("codexbar-cli.log"); + let writer = CappedFileWriter::new(path.clone(), LOG_MAX_BYTES).expect("writer"); + write_panic_record(Some(&writer), "boom payload", "src/lib.rs:7:3", "frame 0"); + let recorded = std::fs::read_to_string(&path).expect("read recorded"); + assert!( + recorded.contains("panic at src/lib.rs:7:3: boom payload"), + "panic record expected: {recorded}" + ); + assert!( + recorded.contains("frame 0"), + "backtrace expected: {recorded}" + ); + } +} diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 8e14f17281..9a13673094 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -566,7 +566,7 @@ impl Default for Settings { impl Settings { /// Get the settings file path pub fn settings_path() -> Option { - dirs::config_dir().map(|p| p.join("CodexBar").join("settings.json")) + crate::logging::config_root().map(|p| p.join("settings.json")) } /// Load settings from disk @@ -597,7 +597,7 @@ impl Settings { /// Marker written after the one-shot "pin tray by default" migration (issue #237). fn promote_tray_default_marker_path() -> Option { - dirs::config_dir().map(|p| p.join("CodexBar").join(".tray-pin-default-v1")) + crate::logging::config_root().map(|p| p.join(".tray-pin-default-v1")) } /// Old builds defaulted `promote_tray_icon` to false and persisted that on any