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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/adapters/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ serde_json = { workspace = true }
# rustls keeps TLS portable (no OpenSSL/native-tls) across mac/win/linux.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
dirs = "5"
# In-process Mutex for the consent cache. parking_lot (already in the tree via tauri) gives
# poison-free guards, so lock sites read as locking, not error handling.
parking_lot = "0.12"

# OS keychain for OUR secrets (refreshed tokens), per-OS backend so the workspace builds
# cross-platform (incl. Linux/Windows CI). Runtime keychain access is only exercised by the
Expand Down
71 changes: 71 additions & 0 deletions crates/adapters/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Reuses the Claude Code CLI's existing OAuth login (ADR 0012, metadata-only discovery →
//! per-source opt-in applies at the app layer). Order: the plain file first, then the macOS
//! Keychain (which may prompt the user the first time).
use std::path::{Path, PathBuf};
use std::sync::Arc;

use async_trait::async_trait;
Expand All @@ -18,6 +19,33 @@ use crate::{KeyringSecretStore, ReqwestHttp, SystemClock, KEYCHAIN_SERVICE};
#[derive(Debug, Default, Clone, Copy)]
pub struct ClaudeCredentials;

impl ClaudeCredentials {
/// Metadata-only presence check (ADR 0012): does a Claude Code login *exist* on this
/// machine? Checks the credentials file's existence and the Keychain item's presence — it
/// never opens, decrypts, or parses the token. This is the discovery path: the secret is
/// only read later via [`OAuthCredentialSource::load`], after the user opts the source in.
pub fn is_present() -> bool {
file_present() || keychain_present()
}
Comment on lines +27 to +29
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Jun 1, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 '\b(spawn_blocking|timeout)\b' --type=rust
rg -nP -C3 'is_present\s*\(' --type=rust

Repository: ogrodev/MLT

Length of output: 3990


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect claude adapter presence helpers around keychain_present/file_present
rg -n "fn (file_present|keychain_present|is_present)\b" crates/adapters/src/claude.rs
rg -n "keychain_present|security|find-generic-password" crates/adapters/src/claude.rs
wc -l crates/adapters/src/claude.rs

# Print relevant parts without dumping entire file
# (Try to stay within ~200 lines around the match)
for pat in "fn keychain_present" "fn file_present" "fn is_present" "security" "find-generic-password"; do
  echo "----- context for $pat -----"
  rg -n -C5 "$pat" crates/adapters/src/claude.rs || true
done

# 2) Confirm callers don't add timeout/spawn_blocking wrappers
# Search for probe.is_present callsites + any timeout/spawn_blocking around them in the async call chain.
rg -n "probe\.is_present\(&" -S crates src-tauri || true
rg -n "sources\.probe\.is_present\(&" -S src-tauri crates || true

# 3) Specifically scan sources.rs to see how LocalSourceProbe is used (no wrapper inside it?)
rg -n "impl SourceProbe for LocalSourceProbe|LocalSourceProbe::is_present|timeout|spawn_blocking" crates/adapters/src/sources.rs
sed -n '1,220p' crates/adapters/src/sources.rs

Repository: ogrodev/MLT

Length of output: 6499


Presence probe can block tokio runtime and lacks per-probe timeout

crates/adapters/src/claude.rs (keychain_present, ~147-153) runs /usr/bin/security find-generic-password ... via synchronous std::process::Command::output() with no timeout; it’s called directly from the async LocalSourceProbe::is_present (crates/adapters/src/sources.rs) without spawn_blocking/bounded wait. If security stalls, it can block runtime worker threads. Errors are mapped to false (unwrap_or(false)), but hangs aren’t bounded—ADR 0015 requires a per-probe timeout/failure gate for upstream probes. Move the blocking subprocess off the async executor and enforce a hard timeout (and kill the child on timeout).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/adapters/src/claude.rs` around lines 27 - 29, The keychain probe
(keychain_present) runs a blocking std::process::Command::output() directly from
the async path (is_present called by LocalSourceProbe::is_present), which can
stall the tokio runtime and has no timeout; change the probe to execute off the
async executor and enforce a hard timeout: either (preferred) rewrite
keychain_present to use tokio::process::Command (spawn the child, await output)
and wrap the await in tokio::time::timeout with a short bound, killing the
tokio::process::Child via child.kill() on timeout, or (if keeping std::process)
call it inside tokio::task::spawn_blocking and wrap that future in
tokio::time::timeout but ensure you spawn the child so you can kill it if the
timeout fires; update the call site in is_present (claude.rs) to await the new
async-safe probe (or await the spawn_blocking) and preserve the file_present
check.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified, but pushing back on doing this in this PR.

The finding is real in the narrow sense (a blocking subprocess awaited on the async executor, no timeout), but two things matter:

  1. This specific probe is the low-risk one. keychain_present calls security find-generic-password without -w — it reads the item's attributes, never decrypts the secret, so it does not trigger a Keychain ACL prompt and returns promptly. The variant that can actually block on a user prompt is read_keychain (-w), which is pre-existing and on the consented load path.
  2. The blocking-in-async pattern is codebase-wide and pre-existing, not introduced here: read_keychain (-w), detect_user_agent (claude --version), and ClaudeCredentials::load's std::fs::read_to_string all run synchronously inside async fns. The same refresh loop that calls this probe also calls claude_usage → load → read_keychain, so wrapping only the new probe in spawn_blocking + timeout would create a divergent pattern and false assurance while the larger exposure (the fetch path) stays unbounded.

ADR 0015's per-probe timeout / failure-gate is a mandated-but-not-yet-implemented resilience pattern. It should be applied uniformly across the adapter's blocking calls and the usage fetch as a dedicated resilience change, not spot-welded onto this one probe. Deferring it there rather than diverging here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@ogrodev, that's a well-reasoned pushback — accepted. The distinction between keychain_present (attributes-only, no ACL prompt) and read_keychain (-w, secret decrypt) is the key point I missed, and the argument for uniform ADR 0015 treatment rather than a divergent spot-fix is sound. Applying spawn_blocking + timeout to only this probe while leaving read_keychain, detect_user_agent, and fs::read_to_string in load() unbounded would create inconsistency without meaningfully reducing the actual exposure.

Would you like me to open a follow-up issue to track the ADR 0015 resilience work (uniform spawn_blocking + bounded timeout across all blocking adapter calls)?


✏️ Learnings added
Learnt from: ogrodev
Repo: ogrodev/MLT PR: 2
File: crates/adapters/src/claude.rs:27-29
Timestamp: 2026-06-01T00:56:24.222Z
Learning: In the `ogrodev/MLT` repository (`crates/adapters/src/claude.rs`), the blocking-in-async pattern (synchronous `std::process::Command` and `std::fs` calls inside async fns) is pre-existing and codebase-wide, covering `read_keychain` (with `-w`), `detect_user_agent`, and `ClaudeCredentials::load`. The `keychain_present` helper intentionally omits `-w` so it reads only Keychain item attributes and never triggers an ACL/user prompt. ADR 0015 mandates a per-probe timeout/failure-gate that must be applied uniformly across all blocking adapter calls as a dedicated resilience change, not spot-fixed on individual probes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Thanks — I went ahead and opened #3 to track the uniform ADR 0015 pass (spawn_blocking + bounded timeout across read_keychain, detect_user_agent, load's fs read, and this probe), so no need for the bot to file one.

}

/// Claude Code's plaintext credentials file under a given home dir. Split out so presence can
/// be unit-tested against a temp home without reading anything.
fn credentials_path(home: &Path) -> PathBuf {
home.join(".claude/.credentials.json")
}

/// Does the credentials file *exist*? `is_file` is a stat — it never opens the file, so even a
/// present-but-garbage file counts as "a login exists here", which is exactly what proves
/// discovery is decoupled from reading the secret.
fn path_present(home: &Path) -> bool {
credentials_path(home).is_file()
}

fn file_present() -> bool {
dirs::home_dir().map(|h| path_present(&h)).unwrap_or(false)
}

#[async_trait]
impl OAuthCredentialSource for ClaudeCredentials {
async fn load(&self) -> Result<OAuthTokens, PortError> {
Expand Down Expand Up @@ -111,6 +139,24 @@ fn read_keychain() -> Option<String> {
None
}

/// Does the macOS Keychain hold Claude Code's credential item? Runs `find-generic-password`
/// **without `-w`**, so it returns only the item's attributes (exit 0 if present) and never the
/// password — presence only, no secret read (ADR 0012). Contrast [`read_keychain`], which
/// passes `-w` to read the token and runs only on the consented `load` path.
#[cfg(target_os = "macos")]
fn keychain_present() -> bool {
std::process::Command::new("/usr/bin/security")
.args(["find-generic-password", "-s", "Claude Code-credentials"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}

#[cfg(not(target_os = "macos"))]
fn keychain_present() -> bool {
false
}

/// Best-effort detection of the installed Claude Code CLI version for the required
/// `User-Agent: claude-code/<version>` header (without it, the endpoint 429s hard).
pub fn detect_user_agent() -> String {
Expand Down Expand Up @@ -168,4 +214,29 @@ mod tests {
fn detect_user_agent_has_the_claude_code_prefix() {
assert!(detect_user_agent().starts_with("claude-code/"));
}

#[test]
fn presence_is_metadata_only_and_never_reads_the_secret() {
let base = std::env::temp_dir().join(format!("mlt-presence-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);

// No credentials file ⇒ not present.
assert!(
!path_present(&base),
"absent credentials file ⇒ not present"
);

std::fs::create_dir_all(base.join(".claude")).unwrap();
// Deliberately INVALID content: discovery reports "present" from existence alone,
// while the real secret path (`parse_creds`) rejects it — proof the probe never reads
// or parses the credential it discovered.
std::fs::write(credentials_path(&base), "not a real credential").unwrap();
assert!(path_present(&base), "existing file ⇒ present (stat only)");
assert!(
parse_creds("not a real credential").is_err(),
"the secret-reading path would reject this content"
);

let _ = std::fs::remove_dir_all(&base);
}
}
149 changes: 149 additions & 0 deletions crates/adapters/src/consent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! File-backed [`ConsentStore`]: the user's per-source opt-in, persisted as a small JSON map
//! (`{ "<source-id>": true }`) in the app config dir. Consent is **not** a secret, so it lives
//! here as plain settings — never in the keychain (ADR 0012). A missing entry means the source
//! is **disabled**, so on a fresh install nothing is read until the user opts in.
use std::collections::BTreeMap;
use std::path::PathBuf;

use parking_lot::Mutex;

use mlt_core::domain::ProviderId;
use mlt_core::ports::{ConsentStore, PortError};

/// In-memory consent map with write-through to a JSON file. The map is the source of truth at
/// runtime (so reads on the refresh hot path never touch disk); every change is persisted so
/// the choice survives a restart.
#[derive(Debug)]
pub struct FileConsentStore {
path: PathBuf,
state: Mutex<BTreeMap<String, bool>>,
}

impl FileConsentStore {
/// Load consent from `path`. Best-effort: a missing or unparseable file starts empty
/// (every source opted-out), so a corrupt settings file can never accidentally enable a
/// source — it fails closed.
pub fn load(path: PathBuf) -> Self {
let state = std::fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str::<BTreeMap<String, bool>>(&raw).ok())
.unwrap_or_default();
Self {
path,
state: Mutex::new(state),
}
}

fn persist(&self, map: &BTreeMap<String, bool>) -> Result<(), PortError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| PortError::Io(e.to_string()))?;
}
let json = serde_json::to_string_pretty(map).map_err(|e| PortError::Io(e.to_string()))?;
std::fs::write(&self.path, json).map_err(|e| PortError::Io(e.to_string()))
}
}

impl ConsentStore for FileConsentStore {
fn is_enabled(&self, id: &ProviderId) -> Result<bool, PortError> {
Ok(self.state.lock().get(id.as_str()).copied().unwrap_or(false))
}

fn set_enabled(&self, id: &ProviderId, enabled: bool) -> Result<(), PortError> {
let mut map = self.state.lock();
let previous = map.insert(id.as_str().to_string(), enabled);
if let Err(e) = self.persist(&map) {
// Persist failed — undo the in-memory change so runtime consent never diverges
// from disk. Fail closed: a source is only ever treated as opted-in once that
// choice has been durably recorded.
match previous {
Some(prev) => {
map.insert(id.as_str().to_string(), prev);
}
None => {
map.remove(id.as_str());
}
}
return Err(e);
}
Ok(())
}
}

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

fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"mlt-consent-test-{}-{tag}/consent.json",
std::process::id()
))
}

#[test]
fn unknown_source_defaults_to_disabled() {
let store = FileConsentStore::load(temp_path("default"));
assert!(!store.is_enabled(&ProviderId::new("claude-code")).unwrap());
}

#[test]
fn opt_in_persists_across_a_reload() {
let path = temp_path("persist");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let id = ProviderId::new("claude-code");

let store = FileConsentStore::load(path.clone());
store.set_enabled(&id, true).unwrap();
// A fresh instance reading the same file (i.e. an app restart) still sees the opt-in.
let reloaded = FileConsentStore::load(path.clone());
assert!(
reloaded.is_enabled(&id).unwrap(),
"consent survives restart"
);

// Opting back out is likewise persisted.
reloaded.set_enabled(&id, false).unwrap();
assert!(!FileConsentStore::load(path.clone())
.is_enabled(&id)
.unwrap());

let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn per_source_toggles_are_independent() {
let path = temp_path("independent");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let store = FileConsentStore::load(path.clone());

store.set_enabled(&ProviderId::new("a"), true).unwrap();
store.set_enabled(&ProviderId::new("b"), false).unwrap();
assert!(store.is_enabled(&ProviderId::new("a")).unwrap());
assert!(!store.is_enabled(&ProviderId::new("b")).unwrap());

let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn failed_persist_leaves_runtime_consent_unchanged() {
let dir = std::env::temp_dir().join(format!(
"mlt-consent-test-{}-failclosed",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Put a *file* where the consent dir's parent should be, so `create_dir_all` (and
// therefore `persist`) fails deterministically — no real disk fault needed.
let blocker = dir.join("blocker");
std::fs::write(&blocker, "x").unwrap();
let store = FileConsentStore::load(blocker.join("consent.json"));
let id = ProviderId::new("claude-code");

assert!(store.set_enabled(&id, true).is_err(), "persist must fail");
// The opt-in must NOT have taken effect in memory: disk and runtime stay in lockstep,
// so a source is never read on the strength of a write that never landed.
assert!(!store.is_enabled(&id).unwrap());

let _ = std::fs::remove_dir_all(&dir);
}
}
4 changes: 4 additions & 0 deletions crates/adapters/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@

pub mod claude;
pub mod clock;
pub mod consent;
pub mod http;
pub mod secrets;
pub mod sources;

pub use claude::{claude_strategy, detect_user_agent, ClaudeCredentials};
pub use clock::SystemClock;
pub use consent::FileConsentStore;
pub use http::ReqwestHttp;
pub use secrets::KeyringSecretStore;
pub use sources::LocalSourceProbe;

/// Keychain service name under which MLT stores its own secrets.
pub const KEYCHAIN_SERVICE: &str = "com.bigshotpictures.mlt";
36 changes: 36 additions & 0 deletions crates/adapters/src/sources.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! [`SourceProbe`] implementation: metadata-only discovery of local sources (ADR 0012).
//!
//! Dispatches a source id to its presence check. Every branch decides presence from existence
//! alone (a credentials file, a Keychain item) and never reads a secret — the per-source
//! checks live with their credential adapter (e.g. [`crate::claude::ClaudeCredentials::is_present`]).
use async_trait::async_trait;

use mlt_core::domain::ProviderId;
use mlt_core::ports::SourceProbe;

use crate::claude::ClaudeCredentials;

/// Probes the real machine for each known source. Unknown ids report absent rather than
/// erroring, so the catalog can list a source before its probe exists.
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalSourceProbe;

#[async_trait]
impl SourceProbe for LocalSourceProbe {
async fn is_present(&self, id: &ProviderId) -> bool {
match id.as_str() {
"claude-code" => ClaudeCredentials::is_present(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: ClaudeCredentials::is_present() is synchronous (calls std::process::Command::output() for the macOS Keychain check) but is invoked directly from the async SourceProbe::is_present. This blocks the tokio worker thread if the security subprocess stalls. Wrap the call in tokio::task::spawn_blocking and add a bounded timeout (e.g., tokio::time::timeout) so a hung child process cannot starve the runtime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/adapters/src/sources.rs, line 22:

<comment>`ClaudeCredentials::is_present()` is synchronous (calls `std::process::Command::output()` for the macOS Keychain check) but is invoked directly from the async `SourceProbe::is_present`. This blocks the tokio worker thread if the `security` subprocess stalls. Wrap the call in `tokio::task::spawn_blocking` and add a bounded timeout (e.g., `tokio::time::timeout`) so a hung child process cannot starve the runtime.</comment>

<file context>
@@ -0,0 +1,36 @@
+impl SourceProbe for LocalSourceProbe {
+    async fn is_present(&self, id: &ProviderId) -> bool {
+        match id.as_str() {
+            "claude-code" => ClaudeCredentials::is_present(),
+            _ => false,
+        }
</file context>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified, but deferring (same as CodeRabbit's note on the same probe). It is a real blocking-in-async call, but:

  1. This probe runs security find-generic-password without -w — an attribute lookup, no decrypt — so it does not trigger a Keychain ACL prompt and returns promptly. The prompt-capable -w read is read_keychain, on the consented load path.
  2. The blocking-in-async pattern is pre-existing and shared by read_keychain (-w), detect_user_agent (claude --version), and load's std::fs::read_to_string — all driven by the same refresh loop. Wrapping only this probe in spawn_blocking + timeout would diverge and give false assurance while the larger exposure stays unbounded.

ADR 0015's per-probe timeout/failure-gate is mandated but not yet implemented; it should land as one uniform resilience pass over the probe and fetch paths, not a spot-fix here. Tracking it there rather than diverging in this consent PR.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Tracked as #3 — uniform spawn_blocking + bounded timeout across the probe and fetch paths (per ADR 0015), rather than a spot-fix here.

_ => false,
}
}
}

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

#[tokio::test]
async fn unknown_source_is_absent() {
assert!(!LocalSourceProbe.is_present(&ProviderId::new("nope")).await);
}
}
2 changes: 2 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
pub mod domain;
pub mod ports;
pub mod providers;
pub mod sources;

pub use domain::*;
pub use ports::*;
pub use providers::*;
pub use sources::*;
17 changes: 17 additions & 0 deletions crates/core/src/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ pub trait OAuthCredentialSource: Send + Sync {
async fn load(&self) -> Result<OAuthTokens, PortError>;
}

/// Metadata-only presence detection for a discoverable local source (ADR 0012). An
/// implementation MUST decide presence from **existence alone** — a credentials file on
/// disk, a Keychain item being present — and MUST NOT read, decrypt, or parse any secret.
/// Returning `true` means "a login for this source exists here", never that it was read.
#[async_trait]
pub trait SourceProbe: Send + Sync {
async fn is_present(&self, id: &ProviderId) -> bool;
}

/// Persists the user's per-source opt-in. Consent is **not** a secret, so it lives here —
/// a plain settings store — never in the keychain (ADR 0012). A source with no stored
/// entry is treated as **disabled**: nothing is read until the user explicitly opts in.
pub trait ConsentStore: Send + Sync {
fn is_enabled(&self, id: &ProviderId) -> Result<bool, PortError>;
fn set_enabled(&self, id: &ProviderId, enabled: bool) -> Result<(), PortError>;
}

#[async_trait]
pub trait UsageRepo: Send + Sync {
async fn save(&self, snapshot: &UsageSnapshot) -> Result<(), PortError>;
Expand Down
Loading