diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1a119140..32bbb07e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -32,7 +32,7 @@ jobs: type=ref,event=tag type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }} + type=raw,value=latest - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 diff --git a/.github/workflows/ppa-publish.yml b/.github/workflows/ppa-publish.yml index baccd0de..4aef36b3 100644 --- a/.github/workflows/ppa-publish.yml +++ b/.github/workflows/ppa-publish.yml @@ -22,6 +22,9 @@ on: env: DEBIAN_FRONTEND: noninteractive +permissions: + contents: read + jobs: publish-ppa: runs-on: ubuntu-latest @@ -102,7 +105,7 @@ jobs: pkg-config - name: Set up Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable @@ -236,7 +239,7 @@ jobs: - name: Upload artifacts if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c1a64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ppa-source-packages path: /tmp/packages/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c748942..38405814 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: tags: - "v*" +# Least privilege: read-only token by default, elevated per job below. +permissions: + contents: read + jobs: build: strategy: @@ -71,6 +75,8 @@ jobs: release: needs: build runs-on: ubuntu-latest + permissions: + contents: write # creates the GitHub release and uploads assets steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -243,6 +249,8 @@ jobs: name: Trigger npm publish needs: release runs-on: ubuntu-latest + permissions: + actions: write # gh workflow run steps: - name: Dispatch npm-publish workflow env: @@ -256,6 +264,8 @@ jobs: name: Trigger PPA publish needs: release runs-on: ubuntu-latest + permissions: + actions: write # gh workflow run steps: - name: Dispatch ppa-publish workflow env: @@ -269,6 +279,8 @@ jobs: name: Trigger Docker publish needs: release runs-on: ubuntu-latest + permissions: + actions: write # gh workflow run steps: - name: Dispatch docker workflow env: diff --git a/.github/workflows/security-check.yml b/.github/workflows/security-check.yml index 862501aa..b4ef0f4e 100644 --- a/.github/workflows/security-check.yml +++ b/.github/workflows/security-check.yml @@ -1,15 +1,12 @@ name: security-check +# No paths filter: "cargo audit" is a required branch-protection check, and a +# path-filtered required check never reports on PRs that skip it — blocking +# the merge forever. Audit is cheap with a warm cache; run it everywhere. on: push: branches: [master] - paths: - - "Cargo.lock" - - "Cargo.toml" pull_request: - paths: - - "Cargo.lock" - - "Cargo.toml" permissions: contents: read diff --git a/.github/workflows/vendored-file-warning.yml b/.github/workflows/vendored-file-warning.yml index 206eb0bd..93c239eb 100644 --- a/.github/workflows/vendored-file-warning.yml +++ b/.github/workflows/vendored-file-warning.yml @@ -10,7 +10,7 @@ permissions: {} jobs: warn: - if: github.event.pull_request.user.login != 'getappz' + if: github.event.pull_request.user.login != 'getappz' && github.event.pull_request.user.type != 'Bot' runs-on: ubuntu-latest steps: - name: Warn on vendored file changes diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 10b87db8..4ed2e502 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -5,6 +5,9 @@ on: types: [published] workflow_dispatch: +permissions: + contents: read + jobs: publish: runs-on: windows-latest @@ -19,7 +22,7 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | $id = "getappz.agentflare" - $urls = gh release view --json assets --jq '.assets[] | select(.name | test("\\.(exe|msi)$")) | .url' + $urls = gh release view --json assets --jq '.assets[] | select(.name | test("windows.*\\.(zip|exe|msi)$")) | .url' if (-not $urls) { throw "No winget installer assets found" } komac update $id --version "$env:RELEASE_TAG" --urls $urls komac cleanup --only-merged diff --git a/.gitignore b/.gitignore index 29f28d12..4619ae3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ /target /docs/superpowers/ /.worktrees/ + +# Local onpush doc-generation tooling (machine-specific, not part of the project) +/.onpush/ +/onpush_run.log +/install_onpush.ps1 +/run_onpush.bat +/serve_docs.py diff --git a/Dockerfile b/Dockerfile index a1643412..2f6a2fcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,15 @@ FROM debian:13-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - sudo curl ca-certificates \ + curl ca-certificates \ && rm -rf /var/lib/apt/lists/* -RUN curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/getappz/agentflare/main/install.sh | sh +RUN useradd --create-home agentflare +USER agentflare +WORKDIR /home/agentflare -ENV PATH="/root/.local/bin:${PATH}" +RUN curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/getappz/agentflare/master/install.sh | sh + +ENV PATH="/home/agentflare/.local/bin:${PATH}" ENTRYPOINT ["agentflare"] diff --git a/crates/agent-registry/src/detect.rs b/crates/agent-registry/src/detect.rs index 14505940..3e89e50d 100644 --- a/crates/agent-registry/src/detect.rs +++ b/crates/agent-registry/src/detect.rs @@ -28,6 +28,7 @@ pub static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// the approach used by `agents-cli`'s `findInPath` and `caam`'s /// `findBinary`, minus their shims-dir exclusion (agentflare has no shims /// directory yet). +#[must_use] pub fn find_binary(names: &[&str]) -> Option { let path_var = std::env::var_os("PATH")?; for dir in std::env::split_paths(&path_var) { @@ -110,6 +111,7 @@ mod find_binary_tests { /// Find the first `\d+\.\d+\.\d+`-shaped substring in `text` (a `--version` /// command's combined stdout+stderr). Hand-rolled instead of pulling in the /// `regex` crate for one pattern. +#[must_use] pub fn extract_version(text: &str) -> Option { let chars: Vec = text.chars().collect(); for start in 0..chars.len() { @@ -196,7 +198,7 @@ impl VersionRunner for RealVersionRunner { fn run_version_command(binary: &Path, args: &[&str]) -> Result { let binary = binary.to_path_buf(); let binary_for_thread = binary.clone(); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let args: Vec = args.iter().map(std::string::ToString::to_string).collect(); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { @@ -237,15 +239,13 @@ pub fn resolve_version_with( .and_then(|m| m.modified()) .map_err(|e| format!("could not stat {}: {e}", binary_path.display()))? .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + .map_or(0, |d| d.as_secs()); let binary_path_str = binary_path.to_string_lossy().into_owned(); - if let Some(entry) = cache.get(agent_key) { - if entry.binary_path == binary_path_str && entry.mtime == mtime { + if let Some(entry) = cache.get(agent_key) + && entry.binary_path == binary_path_str && entry.mtime == mtime { return Ok(entry.version.clone()); } - } let raw = runner.run(binary_path, version_args)?; let version = extract_version(&raw) diff --git a/crates/agent-registry/src/registry.rs b/crates/agent-registry/src/registry.rs index a96b485a..150b1143 100644 --- a/crates/agent-registry/src/registry.rs +++ b/crates/agent-registry/src/registry.rs @@ -30,6 +30,7 @@ pub enum Agent { } impl Agent { + #[must_use] pub fn as_str(self) -> &'static str { match self { Agent::ClaudeCode => "claude-code", @@ -277,6 +278,7 @@ pub static REGISTRY: &[AgentSpec] = &[ /// Not yet consumed outside tests — wired up by the upcoming agent detection /// engine and CLI commands. #[allow(dead_code)] +#[must_use] pub fn spec(agent: Agent) -> &'static AgentSpec { REGISTRY .iter() diff --git a/crates/ponytail/src/config.rs b/crates/ponytail/src/config.rs index 72455ea0..340d3eb4 100644 --- a/crates/ponytail/src/config.rs +++ b/crates/ponytail/src/config.rs @@ -21,6 +21,15 @@ pub fn normalize_persisted_mode(mode: &str) -> Option<&'static str> { normalize_mode(mode).or_else(|| normalize_config_mode(mode)) } +/// Like `normalize_config_mode`, but also accepts user-defined custom skill +/// names (which are discovered at runtime, so they can't be `&'static str`). +pub fn normalize_extended_mode(mode: &str) -> Option { + let m = mode.trim().to_lowercase(); + normalize_config_mode(&m) + .map(str::to_string) + .or_else(|| crate::sub_skills::get_custom(&m).map(|_| m)) +} + pub fn is_deactivation(text: &str) -> bool { let t = text.trim().to_lowercase(); let t = t.trim_end_matches(|c: char| c == '.' || c == '!' || c == '?' || c.is_whitespace()); @@ -45,29 +54,29 @@ struct ConfigFile { pub fn default_mode() -> String { if let Ok(val) = std::env::var("PONYTAIL_DEFAULT_MODE") - && let Some(m) = normalize_config_mode(&val) + && let Some(m) = normalize_extended_mode(&val) { - return m.to_string(); + return m; } if let Ok(data) = std::fs::read_to_string(config_path()) && let Ok(cfg) = serde_json::from_str::(&data) && let Some(mode) = cfg.default_mode - && let Some(m) = normalize_config_mode(&mode) + && let Some(m) = normalize_extended_mode(&mode) { - return m.to_string(); + return m; } DEFAULT_MODE.to_string() } pub fn set_default_mode(mode: &str) -> Result<(), String> { - let normalized = normalize_config_mode(mode).ok_or_else(|| format!("invalid mode: {mode}"))?; + let normalized = normalize_extended_mode(mode).ok_or_else(|| format!("invalid mode: {mode}"))?; let dir = config_dir(); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let mut cfg: ConfigFile = std::fs::read_to_string(config_path()) .ok() .and_then(|d| serde_json::from_str(&d).ok()) .unwrap_or_default(); - cfg.default_mode = Some(normalized.to_string()); + cfg.default_mode = Some(normalized); let json = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; std::fs::write(config_path(), json).map_err(|e| e.to_string())?; Ok(()) diff --git a/crates/ponytail/src/instructions.rs b/crates/ponytail/src/instructions.rs index 699d03bb..8d15c0f6 100644 --- a/crates/ponytail/src/instructions.rs +++ b/crates/ponytail/src/instructions.rs @@ -53,31 +53,41 @@ pub fn download_skill() -> Result { } pub fn build(mode: &str, skill_path: Option<&Path>) -> Instructions { - let effective = config::normalize_persisted_mode(mode) - .unwrap_or(config::DEFAULT_MODE); - - if crate::sub_skills::get(effective).is_some() || crate::sub_skills::get_custom(effective).is_some() { + // Custom skill names aren't in the static mode lists, so keep them as-is + // instead of collapsing them to the default mode. + let effective: String = config::normalize_persisted_mode(mode) + .map(str::to_string) + .or_else(|| crate::sub_skills::get_custom(mode).map(|_| mode.to_string())) + .unwrap_or_else(|| config::DEFAULT_MODE.to_string()); + + if crate::sub_skills::get(&effective).is_some() { return Instructions { - mode: effective.to_string(), + mode: effective.clone(), body: format!( "PONYTAIL MODE ACTIVE — level: {effective}. Behavior defined by /ponytail-{effective} skill." ), }; } + // Custom skills have no harness-installed /ponytail- skill to point + // at, so their authored body is delivered inline. + if let Some(body) = crate::sub_skills::get_custom(&effective) { + return Instructions { mode: effective, body }; + } + let skill_body = if let Some(path) = skill_path { std::fs::read_to_string(path).unwrap_or_else(|_| EMBEDDED_SKILL.to_string()) } else { - crate::sub_skills::get_custom(effective) - .or_else(|| std::fs::read_to_string(skill_cache_path()).ok()) + std::fs::read_to_string(skill_cache_path()) + .ok() .or_else(find_workspace_agents_md) .unwrap_or_else(|| EMBEDDED_SKILL.to_string()) }; - let filtered = filter_skill_body(&skill_body, effective); + let filtered = filter_skill_body(&skill_body, &effective); Instructions { - mode: effective.to_string(), + mode: effective, body: filtered, } } diff --git a/crates/ponytail/src/skill-help.md b/crates/ponytail/src/skill-help.md index ba145c0e..21bab1c5 100644 --- a/crates/ponytail/src/skill-help.md +++ b/crates/ponytail/src/skill-help.md @@ -50,9 +50,9 @@ Default mode = `full`, auto-active every session. Change it: export PONYTAIL_DEFAULT_MODE=ultra ``` -**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`): +**Config file** (`~/.config/agentflare/ponytail/config.json`, Windows: `%APPDATA%\agentflare\ponytail\config.json`): ```json -{ "defaultMode": "lite" } +{ "default_mode": "lite" } ``` Set `"off"` to disable auto-activation on session start, activate manually diff --git a/crates/ponytail/src/sub_skills.rs b/crates/ponytail/src/sub_skills.rs index 70521ed9..24f89543 100644 --- a/crates/ponytail/src/sub_skills.rs +++ b/crates/ponytail/src/sub_skills.rs @@ -36,6 +36,11 @@ static CUSTOM_SKILLS: LazyLock> = LazyLock::new(|| { let path = entry.path(); if path.extension().map_or(false, |e| e == "md") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + // Reserved built-in mode/skill names can't be shadowed by + // custom files — a user's full.md must not hijack full mode. + if crate::config::VALID_MODES.contains(&name) { + continue; + } if let Ok(body) = std::fs::read_to_string(&path) { if !body.is_empty() { map.insert(name.to_string(), body); diff --git a/crates/ponytail/src/switcher.rs b/crates/ponytail/src/switcher.rs index 03422408..3f17b47e 100644 --- a/crates/ponytail/src/switcher.rs +++ b/crates/ponytail/src/switcher.rs @@ -38,12 +38,12 @@ pub fn detect(input: &str) -> Option { let prefixed = format!("/ponytail-{name}"); let alt = format!("/ponytail:{name}"); if prompt == prefixed || prompt.starts_with(&format!("{prefixed} ")) { - let normalized = config::normalize_config_mode(&name)?; - return Some(SwitchAction::SetMode(normalized.to_string())); + let normalized = config::normalize_extended_mode(&name)?; + return Some(SwitchAction::SetMode(normalized)); } if prompt == alt || prompt.starts_with(&format!("{alt} ")) { - let normalized = config::normalize_config_mode(&name)?; - return Some(SwitchAction::SetMode(normalized.to_string())); + let normalized = config::normalize_extended_mode(&name)?; + return Some(SwitchAction::SetMode(normalized)); } } @@ -73,20 +73,20 @@ pub fn detect(input: &str) -> Option { if smode.is_empty() { return None; } - let normalized = config::normalize_config_mode(smode)?; - Some(SwitchAction::SetSession(normalized.to_string())) + let normalized = config::normalize_extended_mode(smode)?; + Some(SwitchAction::SetSession(normalized)) } s if all_skill_names().iter().any(|n| n == s) => { - let normalized = config::normalize_config_mode(s)?; - Some(SwitchAction::SetMode(normalized.to_string())) + let normalized = config::normalize_extended_mode(s)?; + Some(SwitchAction::SetMode(normalized)) } "default" => { let dmode = arg; if dmode.is_empty() { return None; } - let normalized = config::normalize_config_mode(dmode)?; - Some(SwitchAction::SetDefault(normalized.to_string())) + let normalized = config::normalize_extended_mode(dmode)?; + Some(SwitchAction::SetDefault(normalized)) } _ => None, } diff --git a/install.sh b/install.sh index fce4f017..76f044ad 100644 --- a/install.sh +++ b/install.sh @@ -19,13 +19,16 @@ set -eu REPO="getappz/agentflare" INSTALL_DIR="${AGENTFLARE_INSTALL_DIR:-$HOME/.local/bin}" # Resolve the script's directory when invoked as a file. When piped via -# `curl ... | sh`, $0 is "sh" (or similar) — the [ -f "$0" ] guard then -# falls back to pwd, which routes to install_download since Cargo.toml -# won't be found there. +# `curl ... | sh`, $0 is "sh" (or similar) — SCRIPT_IS_FILE stays 0 and the +# dispatch below always downloads: trusting pwd would build whatever +# unrelated Cargo.toml the user happens to be sitting in. +SCRIPT_IS_FILE=0 +if [ -n "${0:-}" ] && [ -f "$0" ]; then + SCRIPT_IS_FILE=1 +fi SCRIPT_DIR="$( - src="$0" - if [ -n "$src" ] && [ -f "$src" ]; then - cd "$(dirname "$src")" 2>/dev/null && pwd + if [ "$SCRIPT_IS_FILE" = "1" ]; then + cd "$(dirname "$0")" 2>/dev/null && pwd else pwd fi @@ -132,13 +135,26 @@ install_download() { fi echo "Downloading checksums..." + # Fail closed: an installer that silently skips verification when the + # (much smaller) SHA256SUMS request is blocked or incomplete is exactly + # what a selective MITM wants. AGENTFLARE_SKIP_VERIFY=1 is the escape hatch. if curl -fsSL "$sums_url" -o "$tmpdir/SHA256SUMS" 2>/dev/null; then expected="$(grep "agentflare-${target}.tar.gz" "$tmpdir/SHA256SUMS" | cut -d' ' -f1)" if [ -n "$expected" ]; then verify_checksum "$tmpdir/agentflare.tar.gz" "$expected" + elif [ "${AGENTFLARE_SKIP_VERIFY:-0}" = "1" ]; then + echo " Warning: agentflare-${target}.tar.gz not listed in SHA256SUMS — proceeding unverified (AGENTFLARE_SKIP_VERIFY=1)" + else + echo "Error: agentflare-${target}.tar.gz is not listed in SHA256SUMS — refusing to install unverified." + echo "Set AGENTFLARE_SKIP_VERIFY=1 to bypass (not recommended)." + exit 1 fi + elif [ "${AGENTFLARE_SKIP_VERIFY:-0}" = "1" ]; then + echo " Warning: checksums not available — proceeding unverified (AGENTFLARE_SKIP_VERIFY=1)" else - echo " Warning: checksums not available, skipping verification" + echo "Error: could not download SHA256SUMS — refusing to install unverified." + echo "Set AGENTFLARE_SKIP_VERIFY=1 to bypass (not recommended)." + exit 1 fi tar -xzf "$tmpdir/agentflare.tar.gz" -C "$tmpdir" @@ -230,7 +246,7 @@ case "${1:-}" in echo " AGENTFLARE_INSTALL_DIR Custom install directory (default: ~/.local/bin)" ;; *) - if [ -f "$SCRIPT_DIR/Cargo.toml" ]; then + if [ "$SCRIPT_IS_FILE" = "1" ] && [ -f "$SCRIPT_DIR/Cargo.toml" ]; then install_from_source else install_download diff --git a/install_onpush.ps1 b/install_onpush.ps1 deleted file mode 100644 index ed12fcad..00000000 --- a/install_onpush.ps1 +++ /dev/null @@ -1,2 +0,0 @@ -npm install -g onpush -onpush --version diff --git a/onpush_run.log b/onpush_run.log deleted file mode 100644 index 5f7ec0bf..00000000 --- a/onpush_run.log +++ /dev/null @@ -1,11 +0,0 @@ -Starting onpush generate at Mon 07/06/2026 19:54:39.40 -[onpush] Generating documentation for agentflare -[onpush] Model: claude-sonnet-4-5 -[onpush] Generation type: full -[onpush] Architecture / System Design Document: skipped (unchanged) -[onpush] API / SDK Reference: skipped (unchanged) -[onpush] Business Overview: skipped (unchanged) -[onpush] Security: skipped (unchanged) -[onpush] Testing: skipped (unchanged) -[onpush] [1/1] Generating Product Overview... -^C \ No newline at end of file diff --git a/run_onpush.bat b/run_onpush.bat deleted file mode 100644 index a29f58d8..00000000 --- a/run_onpush.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -cd /d C:\Users\shiva\workspace\leanstack -echo Starting onpush generate at %date% %time% > onpush_run.log -npx --yes onpush@latest generate --verbose --full --type product-overview --model claude-sonnet-4-5 >> onpush_run.log 2>&1 -echo Finished at %date% %time% with exit code %ERRORLEVEL% >> onpush_run.log diff --git a/scripts/release-npm.sh b/scripts/release-npm.sh index b716615b..fe872aea 100644 --- a/scripts/release-npm.sh +++ b/scripts/release-npm.sh @@ -37,15 +37,17 @@ platforms=( download_asset() { local asset="$1" local dest="$2" - if [ ! -f "$dest" ]; then - gh release download "$AGENTFLARE_VERSION" \ - --repo getappz/agentflare \ - --pattern "$asset" \ - --dir "$RELEASE_DIR" || { - echo "Warning: $asset not found, skipping" - return 1 - } - fi + # Always fetch fresh: asset names carry no version, so a leftover file in + # a reused RELEASE_DIR would silently republish the previous release's + # binary under the new version tag. + rm -f "$dest" + gh release download "$AGENTFLARE_VERSION" \ + --repo getappz/agentflare \ + --pattern "$asset" \ + --dir "$RELEASE_DIR" || { + echo "Warning: $asset not found, skipping" + return 1 + } } extract_asset() { @@ -69,6 +71,8 @@ extract_asset() { fi } +skipped_platforms=() + for entry in "${platforms[@]}"; do IFS=":" read -r npm_plat rust_target ext <<<"$entry" IFS="-" read -r os arch <<<"$npm_plat" @@ -76,7 +80,10 @@ for entry in "${platforms[@]}"; do asset="agentflare-${rust_target}.${ext}" archive_path="$RELEASE_DIR/$asset" - download_asset "$asset" "$archive_path" || continue + download_asset "$asset" "$archive_path" || { + skipped_platforms+=("$npm_plat") + continue + } rm -rf "$RELEASE_DIR/npm" mkdir -p "$RELEASE_DIR/npm" @@ -252,4 +259,10 @@ else fi popd +# A release that quietly ships fewer platform packages than declared leaves +# those users with broken npm installs and no CI signal — fail loudly. +if [ "${#skipped_platforms[@]}" -gt 0 ]; then + error "npm publish incomplete for v$AGENTFLARE_NPM_VERSION — missing platform assets: ${skipped_platforms[*]}" +fi + echo "npm publish complete for v$AGENTFLARE_NPM_VERSION" diff --git a/src/auth.rs b/src/auth.rs index 25979e3e..b78c0833 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -207,6 +207,9 @@ pub fn activate_with(agent: &str, profile: &str, reload_daemon: bool, json: bool } } + let conn = crate::auth_db::open_or_rebuild(); + crate::auth_db::touch_last_used(&conn, agent, &profile); + if json { println!("{}", serde_json::json!({ "agent": agent, diff --git a/src/auth_db.rs b/src/auth_db.rs index 24011f78..323b8f00 100644 --- a/src/auth_db.rs +++ b/src/auth_db.rs @@ -183,6 +183,19 @@ pub fn record_error(conn: &Connection, agent: &str, profile: &str, error_msg: &s .ok(); } +/// Stamp a profile as just-activated so `smart_pick`'s recency term is real. +pub fn touch_last_used(conn: &Connection, agent: &str, profile: &str) { + let now = now_iso(); + conn.execute( + "INSERT INTO profile_health (agent, profile, status, error_count_1h, penalty, last_used_at, updated_at) + VALUES (?1, ?2, 'healthy', 0, 0.0, ?3, ?4) + ON CONFLICT(agent, profile) DO UPDATE SET + last_used_at = excluded.last_used_at, updated_at = excluded.updated_at", + params![agent, profile, now, now], + ) + .ok(); +} + pub fn set_cooldown(conn: &Connection, agent: &str, profile: &str, minutes: u32, reason: &str) { let until = chrono::Utc::now() + chrono::Duration::minutes(minutes as i64); conn.execute( diff --git a/src/auth_runner.rs b/src/auth_runner.rs index 57e91e0d..d27c521a 100644 --- a/src/auth_runner.rs +++ b/src/auth_runner.rs @@ -15,8 +15,8 @@ const MAX_RETRIES: usize = 5; pub fn run(agent: &str, args: &[String], json: bool) { let mut remaining = MAX_RETRIES; loop { - let status = spawn_and_capture(agent, args); - match categorize_exit(status) { + let (code, stderr) = spawn_and_capture(agent, args); + match categorize_exit(code, &stderr) { ExitKind::Success => return, ExitKind::RateLimited => { if remaining == 0 { @@ -29,6 +29,7 @@ pub fn run(agent: &str, args: &[String], json: bool) { } let conn = auth_db::open_or_rebuild(); if let Some((profile, _)) = auth_db::get_rotation_last(&conn, agent) { + auth_db::record_error(&conn, agent, &profile, &stderr); auth_db::set_cooldown(&conn, agent, &profile, 30, "rate limit"); } crate::auth::rotate(agent, "smart", json); @@ -39,6 +40,12 @@ pub fn run(agent: &str, args: &[String], json: bool) { thread::sleep(Duration::from_secs(1 + (MAX_RETRIES - remaining) as u64)); } ExitKind::Failure(code) => { + // Feed health scoring even for non-retryable failures so + // smart_pick can demote profiles that keep erroring. + let conn = auth_db::open_or_rebuild(); + if let Some((profile, _)) = auth_db::get_rotation_last(&conn, agent) { + auth_db::record_error(&conn, agent, &profile, &stderr); + } std::process::exit(code); } } @@ -51,8 +58,7 @@ enum ExitKind { Failure(i32), } -fn categorize_exit(status: (i32, String)) -> ExitKind { - let (code, stderr) = status; +fn categorize_exit(code: i32, stderr: &str) -> ExitKind { if code == 0 { return ExitKind::Success; } @@ -160,25 +166,25 @@ mod tests { #[test] fn categorize_rate_limit_detects_429() { - let result = categorize_exit((1, "HTTP 429 Too Many Requests".to_string())); + let result = categorize_exit(1, "HTTP 429 Too Many Requests"); assert!(matches!(result, ExitKind::RateLimited)); } #[test] fn categorize_rate_limit_detects_quota() { - let result = categorize_exit((1, "quota exceeded for today".to_string())); + let result = categorize_exit(1, "quota exceeded for today"); assert!(matches!(result, ExitKind::RateLimited)); } #[test] fn categorize_success_on_zero() { - let result = categorize_exit((0, String::new())); + let result = categorize_exit(0, ""); assert!(matches!(result, ExitKind::Success)); } #[test] fn categorize_failure_on_unknown_error() { - let result = categorize_exit((1, "something went wrong".to_string())); + let result = categorize_exit(1, "something went wrong"); assert!(matches!(result, ExitKind::Failure(1))); } } diff --git a/src/cli/ponytail.rs b/src/cli/ponytail.rs index 2f39f02b..674e4223 100644 --- a/src/cli/ponytail.rs +++ b/src/cli/ponytail.rs @@ -155,6 +155,11 @@ impl PonytailArgs { } PonytailAction::Hook { event } => match event { PonytailHookEvent::SessionStart => { + // A session-scoped override must not outlive its session: + // there is no SessionEnd hook, so clear it when the next + // session starts — otherwise active_mode() reads the stale + // override and set_active() below promotes it to global. + ponytail::clear_session(); let mode = ponytail::active_mode() .unwrap_or_else(ponytail::default_mode); if mode != "off" { diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 36999b49..198ec578 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -3,7 +3,6 @@ //! dependency, not ported code; no /NOTICE entry needed). use crate::optimize; -use crate::optimize::Router; use rmcp::{ handler::server::wrapper::Parameters, model::{ @@ -100,7 +99,8 @@ impl AgentflareMcp { recent_tool_calls: vec![], current_model: None, }; - let router = optimize::KeywordRouter; + // Same router the CLI hook uses — honors AGENTFLARE_ROUTER. + let router = optimize::active_router(); let result = match router.route(&ctx) { Some(nudge) => serde_json::json!({"suggestion": nudge}), None => serde_json::json!({"suggestion": null}), diff --git a/src/paths.rs b/src/paths.rs index 15793851..a676b809 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -18,9 +18,12 @@ pub fn home() -> PathBuf { // parallel test runner. #[cfg(test)] pub(crate) mod test_support { - use std::sync::Mutex; - - static GLOBAL_STATE_LOCK: Mutex<()> = Mutex::new(()); + // One process-wide lock for ALL env mutation in this test binary. + // src/agents.rs already serializes PATH edits on agent_registry's + // PATH_LOCK; using a second, independent lock here would let a + // set_var("AGENTFLARE_HOME_OVERRIDE") race a set_var("PATH") on another + // thread — exactly the UB set_var is unsafe for. + use agent_registry::detect::PATH_LOCK as GLOBAL_STATE_LOCK; pub(crate) fn with_temp_home(f: impl FnOnce() -> T) -> T { let _guard = GLOBAL_STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/pricing.rs b/src/pricing.rs index 169bcf8a..bd0ebba9 100644 --- a/src/pricing.rs +++ b/src/pricing.rs @@ -213,10 +213,15 @@ pub fn lookup_pricing<'a>( return Some(p); } } - for (key, p) in pricing { - if model_id.starts_with(key.as_str()) { - return Some(p); - } + // Longest matching prefix wins — HashMap iteration order is random, so + // taking the first match would resolve ambiguous ids nondeterministically. + if let Some(p) = pricing + .iter() + .filter(|(key, _)| model_id.starts_with(key.as_str())) + .max_by_key(|(key, _)| key.len()) + .map(|(_, p)| p) + { + return Some(p); } { let matches: Vec<&ModelPricing> = pricing diff --git a/src/rollup.rs b/src/rollup.rs index 4047e359..ac667c82 100644 --- a/src/rollup.rs +++ b/src/rollup.rs @@ -163,7 +163,9 @@ use std::collections::{HashMap, HashSet}; /// message.id:requestId dedup guarantee exact. pub(crate) fn sync(conn: &mut Connection, projects_dir: &Path) { let pricing = crate::pricing::load_pricing(); - for path in find_session_files_under(projects_dir) { + let files = find_session_files_under(projects_dir); + prune_deleted_files(conn, &files); + for path in files { let Ok(meta) = std::fs::metadata(&path) else { continue; }; @@ -193,6 +195,38 @@ pub(crate) fn sync(conn: &mut Connection, projects_dir: &Path) { } } +/// Files deleted or moved since the last sync must drop out of the catalog: +/// stale `file_rollup` rows overstate cost forever, and stale `dedup_keys` +/// ownership can wrongly mark a new file's lines as duplicates. +fn prune_deleted_files(conn: &mut Connection, on_disk: &[std::path::PathBuf]) { + let disk: HashSet = on_disk + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + let known: Vec = { + let Ok(mut stmt) = conn.prepare("SELECT file_path FROM session_files") else { + return; + }; + match stmt.query_map([], |row| row.get(0)) { + Ok(rows) => rows.flatten().collect(), + Err(_) => return, + } + }; + let stale: Vec<&String> = known.iter().filter(|k| !disk.contains(*k)).collect(); + if stale.is_empty() { + return; + } + let Ok(tx) = conn.transaction() else { + return; + }; + for path in stale { + tx.execute("DELETE FROM session_files WHERE file_path = ?1", params![path]).ok(); + tx.execute("DELETE FROM file_rollup WHERE file_path = ?1", params![path]).ok(); + tx.execute("DELETE FROM dedup_keys WHERE file_path = ?1", params![path]).ok(); + } + tx.commit().ok(); +} + fn reindex_file( conn: &mut Connection, path: &Path,