Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod client;
pub mod credential_backend;
pub mod credentials;
pub mod identity;
pub mod notice;
pub mod state;
pub mod types;

Expand Down
181 changes: 181 additions & 0 deletions src/auth/notice.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! User-facing "you've been logged out" reminder.
//!
//! When a login expires (refresh token past its lifetime), cloud sync of
//! authorship notes and prompt transcripts silently pauses — queued data stays
//! local until the user logs in again. The daemon logs a warning, but users
//! don't read daemon logs, so this surfaces the state on interactive commands:
//! a short stderr notice with the pending queue counts and the fix.
//!
//! Fires only for users who WERE logged in (stored credentials whose refresh
//! token has expired) — never for users who never logged in. Gated on an
//! interactive stdout so it can't pollute scripts or piped output, emitted at
//! most once per process, and rate-limited to once per 24 hours across
//! processes via a timestamp file.

use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};

use crate::auth::CredentialStore;

/// Minimum seconds between notices across processes (24 hours).
const NOTICE_INTERVAL_SECS: i64 = 24 * 60 * 60;

/// How long a daemon-reported auth failure stays "fresh" (48 hours). The
/// daemon re-records the stamp on every blocked flush attempt, so an active
/// problem keeps the stamp current; a stale stamp after a successful login
/// simply ages out even if the clear was missed.
const SYNC_BLOCKED_FRESH_SECS: i64 = 48 * 60 * 60;

static NOTICE_EMITTED: AtomicBool = AtomicBool::new(false);

fn notice_stamp_path() -> PathBuf {
crate::mdm::utils::home_dir()
.join(".autter")
.join("internal")
.join("logged-out-notice-at")
}

fn sync_blocked_stamp_path() -> PathBuf {
crate::mdm::utils::home_dir()
.join(".autter")
.join("internal")
.join("sync-auth-blocked-at")
}

/// Record that a sync attempt found pending work but no working auth.
/// Called by the daemon's flush loop; read back by [`maybe_warn_logged_out`].
/// This catches the case a pure token-expiry check misses: a refresh token
/// that is valid by timestamp but rejected by the server (revoked, rotated
/// signing keys, etc.).
pub fn record_sync_auth_blocked() {
let path = sync_blocked_stamp_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, unix_now().to_string());
}

/// Clear the blocked stamp after a successful authenticated sync.
pub fn clear_sync_auth_blocked() {
let _ = std::fs::remove_file(sync_blocked_stamp_path());
}

/// True when the daemon recently reported auth-blocked sync attempts.
fn sync_auth_blocked_recently() -> bool {
let Ok(raw) = std::fs::read_to_string(sync_blocked_stamp_path()) else {
return false;
};
let Ok(at) = raw.trim().parse::<i64>() else {
return false;
};
unix_now() - at < SYNC_BLOCKED_FRESH_SECS
}

fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

/// True when the notice was shown within the last [`NOTICE_INTERVAL_SECS`].
fn recently_notified() -> bool {
let Ok(raw) = std::fs::read_to_string(notice_stamp_path()) else {
return false;
};
let Ok(last) = raw.trim().parse::<i64>() else {
return false;
};
unix_now() - last < NOTICE_INTERVAL_SECS
}

fn record_notified() {
let path = notice_stamp_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, unix_now().to_string());
}

/// Count of locally queued items waiting for cloud upload: (notes, transcripts).
/// Best-effort — returns zeros when the databases are unavailable.
fn pending_upload_counts() -> (i64, i64) {
let notes = crate::notes::db::NotesDatabase::global()
.ok()
.and_then(|db| db.lock().ok().map(|lock| lock.count_pending().unwrap_or(0)))
.unwrap_or(0);
let transcripts = crate::authorship::internal_db::InternalDatabase::global()
.ok()
.and_then(|db| {
db.lock()
.ok()
.map(|lock| lock.count_pending_cas().unwrap_or(0))
})
.unwrap_or(0);
(notes, transcripts)
}

/// Print a reminder to stderr when the user's login has expired.
///
/// Call from interactive command paths (autter subcommands and the git proxy).
/// All gates are cheap and short-circuit: terminal check, per-process flag,
/// 24-hour stamp file, then the credentials read.
pub fn maybe_warn_logged_out() {
if !std::io::stdout().is_terminal() {
return;
}
if NOTICE_EMITTED.load(Ordering::Relaxed) || recently_notified() {
return;
}

// Only warn when the user WAS logged in (stored credentials exist) and
// the session no longer works — either the refresh token is past its
// expiry, or the daemon reports that authenticated sync is failing (a
// token that is valid by timestamp but rejected by the server).
let Ok(Some(creds)) = CredentialStore::new().load() else {
return;
};
if !creds.is_refresh_token_expired() && !sync_auth_blocked_recently() {
return;
}

if NOTICE_EMITTED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
record_notified();

let (notes, transcripts) = pending_upload_counts();

eprintln!();
eprintln!("\x1b[1;33m⚠ You've been logged out of autter — cloud sync is paused.\x1b[0m");
if notes > 0 || transcripts > 0 {
eprintln!(
"\x1b[1;33m {} authorship note{} and {} transcript{} are stored locally and will upload once you're back in.\x1b[0m",
notes,
if notes == 1 { "" } else { "s" },
transcripts,
if transcripts == 1 { "" } else { "s" },
);
}
eprintln!("\x1b[1;33m Run \x1b[1;36mautter login\x1b[0m\x1b[1;33m to reconnect.\x1b[0m");
eprintln!();
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn notice_interval_parse_roundtrip() {
// The stamp file format is a bare unix timestamp; make sure the
// parse used by recently_notified accepts what record_notified writes.
let now = unix_now();
let parsed = now.to_string().trim().parse::<i64>().unwrap();
assert_eq!(parsed, now);
assert!(now > 0);
}
}
2 changes: 1 addition & 1 deletion src/authorship/cas_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ pub fn resolve_cas_messages(messages_url: &str) -> Result<Option<Vec<Message>>,
}

let cfg = config::Config::fresh();
let dataplane_url = if cfg.notes_backend_kind() == config::NotesBackendKind::Http {
let dataplane_url = if cfg.notes_backend_kind().uses_http() {
cfg.notes_backend_url().map(|s| s.to_string())
} else {
None
Expand Down
12 changes: 12 additions & 0 deletions src/authorship/internal_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,21 @@
records.push(row?);
}

Ok(records)

Check warning on line 423 in src/authorship/internal_db.rs

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Migration + app logic + UI in one PR

PR combines database schema changes (authorship/internal_db.rs, notes/db.rs, file_changes/db.rs) with application logic (NotesBackendKind::Http routing in fetch_notes, notes_migrate, log, push_hooks) and user-facing notice/output changes (src/auth/notice.rs wired to src/main.rs startup path). The bundle spans `src/authorship/internal_db.rs`, `src/notes/db.rs`, `src/auth/notice.rs`. Split into two PRs: (1) Migration PR: schema-only changes to internal_db.rs, notes/db.rs, and file_changes/db.rs with safe no-op additions. (2) Feature PR: application logic and UI changes—NotesBackendKind::Http handling in commands, auth notice module, claude_config_registry, and telemetry refactor. This allows migration to be deployed and validated independently before behavior changes. **References:** - https://github.com/advisories/ghsa-jm43-hrq7-r7w6 — XWiki allows privilege escalation through link refactoring · CVE-2025-49580 · GitHub Advisory Database · GitHub ## XWiki allows privilege escalation through link refactoring High s - https://nvd.nist.gov/vuln/detail/CVE-2025-49580 — NVD - CVE-2025-49580 ## CVE-2025-49580 Detail ### Description XWiki is a generic wiki platform. From 8.2 and 7.4.5 until 17.1.0-rc-1, 16.10.4, and 16.4.7, pages can gain script or Suggested fix: Split this PR so the schema/database migration lands first, then the application-logic PR consuming it, then the UI PR. This sequencing de-risks rollback: a bad migration can be reverted before app/UI traffic depends on it. Concrete next step: extract `src/authorship/internal_db.rs` (or its peer in this PR) into its own follow-up branch and open separate PRs. References: https://github.com/advisories/ghsa-jm43-hrq7-r7w6 https://nvd.nist.gov/vuln/detail/CVE-2025-49580

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [ai] Lack of Test for count_pending_cas Method — Risk: 70/100

The newly added count_pending_cas method in InternalDatabase.rs does not have a corresponding test, potentially leading to unverified function behavior.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: src/authorship/internal_db.rs
🛠 AI fix prompt (copy & paste into your coding agent)

Flagged by Autter security & observability checks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [ai] Potential Inefficiency with CAS Queue Handling — Risk: 60/100

Unnecessary queuing and dequeueing without checking if sync is possible might lead to resource exhaustion.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: src/authorship/internal_db.rs
🛠 AI fix prompt (copy & paste into your coding agent)

Flagged by Autter security & observability checks.

}

/// Number of CAS objects still waiting to be uploaded (excluding rows that
/// exhausted their retry budget). Cheap gate for the daemon flush loop so
/// an empty queue never triggers an auth check.
pub fn count_pending_cas(&self) -> Result<i64, AutterError> {
let count = self.conn.query_row(
"SELECT COUNT(*) FROM cas_sync_queue WHERE attempts < 6",
[],
|row| row.get(0),
)?;
Ok(count)
}

/// Delete a CAS sync record (on successful sync)
pub fn delete_cas_sync_record(&mut self, id: i64) -> Result<(), AutterError> {
self.conn
Expand Down
6 changes: 6 additions & 0 deletions src/commands/checkpoint_agent/presets/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ impl AgentPreset for ClaudePreset {
));
}

// This process runs inside the agent's environment: when a harness
// built on the Claude Agent SDK routes hooks through its own
// CLAUDE_CONFIG_DIR, record that directory so install/update runs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [ai] Missing Test for register_active_config_dir Function — Risk: 80/100

The call to register_active_config_dir within claude.rs lacks testing, which may lead to unverified configuration behavior.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: src/commands/checkpoint_agent/presets/claude.rs
🛠 AI fix prompt (copy & paste into your coding agent)

Flagged by Autter security & observability checks.

// keep its hooks maintained too.
crate::mdm::claude_config_registry::register_active_config_dir();

let cwd = parse::required_str(&data, "cwd")?;
let transcript_path = parse::required_str(&data, "transcript_path")?;

Expand Down
3 changes: 2 additions & 1 deletion src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1357,8 +1357,9 @@ fn parse_notes_backend_kind(value: &str) -> Result<NotesBackendKind, String> {
match value.trim().to_lowercase().as_str() {
"git_notes" | "git-notes" => Ok(NotesBackendKind::GitNotes),
"http" => Ok(NotesBackendKind::Http),
"both" => Ok(NotesBackendKind::Both),
_ => Err(format!(
"Invalid notes_backend.kind '{}'. Expected 'git_notes' or 'http'",
"Invalid notes_backend.kind '{}'. Expected 'git_notes', 'http', or 'both'",
value
)),
}
Expand Down
11 changes: 9 additions & 2 deletions src/commands/fetch_notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,15 @@ pub fn handle_fetch_notes(args: &[String]) {
let start = Instant::now();

// When the HTTP notes backend is enabled, warm the local notes-db cache
// from the HTTP backend instead of fetching refs/notes/ai.
if crate::config::Config::get().notes_backend_kind() == NotesBackendKind::Http {
// from the HTTP backend. For the pure Http backend this replaces the git
// fetch; for the Both backend it runs in addition to it.
let backend_kind = crate::config::Config::get().notes_backend_kind();
if backend_kind == NotesBackendKind::Both
&& let Err(e) = crate::git::notes_api::warm_cache_for_remote(&repo, &remote_name)
{
tracing::warn!(%e, "both backend: cloud cache warm failed; continuing with git fetch");
}
if backend_kind == NotesBackendKind::Http {
match crate::git::notes_api::warm_cache_for_remote(&repo, &remote_name) {
Ok(()) => {
let elapsed = start.elapsed();
Expand Down
3 changes: 3 additions & 0 deletions src/commands/git_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ fn handle_git_inner(args: &[String]) {
// Warn (loudly, once per process) if this CLI is below the platform's
// minimum required version. Reads the cached releases payload — no network.
crate::commands::upgrade::maybe_warn_below_min_version();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [ai] Missing User Feedback on Authentication Failure — Risk: 20/100

The proposed changes in PR #34 introduce user prompts for scenarios requiring authentication that previously lacked direct user engagement. While existing functions check authentication, they don't inform users about failures effectively. This enhancement is necessary for user awareness during command execution.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Dependent files: src/api/client.rs, src/commands/login.rs, src/commands/logout.rs
🛠 AI fix prompt (copy & paste into your coding agent)

Flagged by Autter security & observability checks.


// Remind the user when their login has expired and cloud sync is paused.
crate::auth::notice::maybe_warn_logged_out();
});

exit_with_status(exit_status);
Expand Down
8 changes: 5 additions & 3 deletions src/commands/hooks/push_hooks.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
use crate::commands::upgrade;
use crate::config::NotesBackendKind;
use crate::git::cli_parser::{ParsedGitInvocation, is_dry_run};
use crate::git::repository::Repository;
use crate::git::sync_authorship::push_authorship_notes;

pub fn run_pre_push_hook_managed(parsed_args: &ParsedGitInvocation, repository: &Repository) {
upgrade::maybe_schedule_background_update_check();

// When using the HTTP notes backend, skip the git-notes push entirely.
if crate::config::Config::get().notes_backend_kind() == NotesBackendKind::Http {
// Skip the git-notes push when notes don't live in git refs at all.
if !crate::config::Config::get()
.notes_backend_kind()
.uses_git_notes()
{
tracing::debug!("run_pre_push_hook_managed: skipping authorship push (Http backend)");
return;
}
Expand Down
12 changes: 7 additions & 5 deletions src/commands/install_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::mdm::agents::get_all_installers;
use crate::mdm::hook_installer::HookInstallerParams;
use crate::mdm::skills_installer;
use crate::mdm::spinner::{Spinner, print_diff};
use crate::mdm::utils::get_current_binary_path;
use crate::mdm::utils::resolve_hook_binary_path;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -318,8 +318,9 @@ pub fn run(args: &[String]) -> Result<HashMap<String, String>, AutterError> {
let _ = crate::daemon::telemetry_handle::init_daemon_telemetry_handle();
}

// Get absolute path to the current binary
let binary_path = get_current_binary_path()?;
// Hook configs need a binary path that outlives this process; prefer the
// stable installed binary when running from a cargo build directory.
let binary_path = resolve_hook_binary_path()?;
persist_install_config(&binary_path, options.dry_run)?;
let params = HookInstallerParams { binary_path };

Expand Down Expand Up @@ -448,8 +449,9 @@ pub fn run_uninstall(args: &[String]) -> Result<HashMap<String, String>, AutterE
}
}

// Get absolute path to the current binary
let binary_path = get_current_binary_path()?;
// Match the path resolution used at install time so uninstall targets the
// same hook entries.
let binary_path = resolve_hook_binary_path()?;
let params = HookInstallerParams { binary_path };

// Run async operations with smol and convert result
Expand Down
7 changes: 4 additions & 3 deletions src/commands/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::authorship::ignore::effective_ignore_patterns;
use crate::authorship::stats::{
stats_for_commit_stats_with_parent_and_authorship, write_stats_to_terminal,
};
use crate::config::{Config, NotesBackendKind};
use crate::config::Config;
use crate::error::AutterError;
use crate::git::repository::Repository;
use crossterm::{
Expand Down Expand Up @@ -231,9 +231,10 @@ fn run_log(args: &[String]) -> Result<ExitStatus, LogError> {
}

fn run_plain_log(global_args: &[String], git_log_args: &[String]) -> Result<ExitStatus, LogError> {
if Config::get().notes_backend_kind() != NotesBackendKind::GitNotes {
if !Config::get().notes_backend_kind().uses_git_notes() {
return Err(LogError::Message(
"plain git log --notes=ai only supports the git_notes backend".to_string(),
"plain git log --notes=ai only supports backends that store notes in git refs"
.to_string(),
));
}

Expand Down
8 changes: 4 additions & 4 deletions src/commands/notes_migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

use crate::api::client::{ApiClient, ApiContext};
use crate::api::types::{NoteEntry, NotesUploadRequest};
use crate::config::{Config, NotesBackendKind};
use crate::config::Config;
use crate::error::AutterError;
use crate::git::find_repository;
use crate::notes::db::NotesDatabase;
Expand Down Expand Up @@ -38,11 +38,11 @@ pub fn handle_notes_migrate(args: &[String]) {
}
}

// 1. Refuse to run unless notes_backend.kind == http.
// 1. Refuse to run unless the backend syncs notes over HTTP.
let cfg = Config::fresh();
if cfg.notes_backend_kind() != NotesBackendKind::Http {
if !cfg.notes_backend_kind().uses_http() {
eprintln!(
"error: `autter notes migrate` requires notes_backend.kind = http.\n\
"error: `autter notes migrate` requires notes_backend.kind = http (or both).\n\
Current backend: {}\n\
\n\
To enable cloud sync, run:\n\
Expand Down
Loading