Two parallel agents (both Sonnet 4.6 via the just-activated tier system)
extended the substrate-unified-registry. First end-to-end proof that the
Phase 4 router refactor saves money: no Opus spawns this round.
PART 1 — `kei-registry secrets` subcommand (Agent A — code-implementer)
Reads env-var NAMES from `~/.claude/secrets/.env` (RULE 0.8 SSoT) and
per-project `secrets/*.env`, greps the kit tree for usages, reports
orphans (defined but unreferenced). Live run on this kit found 26 keys,
11 ORPHAN — actionable cleanup candidates incl. GitHub OAuth client
creds, Godaddy keys, KeiGit admin creds, KEI_MEMORY_TOKEN.
Files:
- `_primitives/_rust/kei-registry/src/secrets.rs` (152 LOC) — pure
read-side cube. SecretsReport + KeyRow types, env-file parser
(KEY=value lines, validates `^[A-Z][A-Z0-9_]*$`), walkdir-based
scanner with skips (target/ node_modules/ .git/ _generated/),
word-boundary regex per key. ASCII + JSON render.
- `_primitives/_rust/kei-registry/src/secrets_tests.rs` (125 LOC) —
5 unit tests covering env parse, scan correctness, word-boundary
regression (`MY_KEY` ≠ `MY_KEY_EXTRA`), JSON roundtrip, ORPHAN marker.
- `_primitives/_rust/kei-registry/src/secrets_handler.rs` (58 LOC) —
CLI dispatch handler.
- `cli.rs`, `handlers.rs`, `lib.rs` extended with Secrets variant.
Resolves the asymmetry called out in the design discussion: paths got
atomization (commit 3422bdc), keys get a query-layer instead. Reason:
env-var NAMES are already public and stable; opaque atom-DNA over them
adds zero security and full overhead. Orphan detection is the unique
value, and a 30-LOC subcommand delivers it without a per-key atom file.
PART 2 — kei-model catalog extension (Agent B — fal-ai-runner)
Adds 10 generation-model entries with VERIFIED pricing per RULE 0.4:
- google: gemini-3-1-flash-image, gemini-3-pro-image
- fal.ai: flux-2-pro, flux-pro-1-1, kling-o3, veo-3, ideogram-v3, recraft-v3
- elevenlabs: elevenlabs-v3, elevenlabs-multilingual-v2
Pricing sourced from each provider's public pricing page (URLs cited
per row in `notes` + `source_url` fields); 8/10 verified, 2 marked
needs-verification (gemini-3-pro-image price not found on public page).
Schema additions to `_primitives/_rust/kei-model/src/model.rs` to
support the new entries without `provider = "local"` placeholder:
- Provider enum + 3 variants: Google, Fal, Elevenlabs (with as_str
+ parse impls).
- Capability enum + 9 variants: image-gen, text-to-image, image-edit,
video-gen, text-to-video, image-to-video, voice-gen, text-to-speech,
voice-clone (with serde rename + as_str + parse).
Pricing struct unchanged: per-image / per-second / per-1k-chars unit
costs ride existing `output_per_mtok_micro` field with the unit
documented in `notes` (e.g. "Per-image cost. 1 unit = 1 image."). A
proper Pricing.unit field is a follow-up.
Files:
- `_primitives/_rust/kei-model/src/model.rs` (+24 LOC enum extensions)
- `_primitives/_rust/kei-model/data/models.toml` (+216 LOC, 471 total)
`kei-model list` returns the full 21-model catalog incl. new providers.
Tests:
- kei-registry: 25 passed (existing + 5 secrets tests + 10 status)
- kei-model: 0 (no unit tests in crate, parser smoke via list)
- agent-assembler: 29 passed (no regressions)
Verification (cited):
- `./target/release/kei-registry secrets --env-file ~/.claude/secrets/.env`
emits real report 26/11 orphan.
- `./target/release/kei-model list` parses all 21 entries cleanly.
- `cargo build --release --workspace` clean.
What this does NOT do (deferred):
- Pricing.unit field (per-mtok / per-image / per-second / per-1k-chars
discriminator) — needs Rust struct refactor + cost-estimator update.
- `secrets` skip-list extension (worktrees, _ts_packages/node_modules
duplicate counts) — minor noise.
- gemini-3-pro-image pricing (no public page; vendor-specific quote
needed).
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
- Pricing.unit field for cost-estimator correctness on gen models
- secrets scan: skip .claude/worktrees/ to avoid duplicate counts
- gemini-3-pro-image price verification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
4.1 KiB
Rust
125 lines
4.1 KiB
Rust
//! Tests for secrets.rs — orphan-detection, env-parse, word-boundary, JSON roundtrip.
|
|
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
|
|
fn write_file(dir: &Path, name: &str, content: &str) {
|
|
let p = dir.join(name);
|
|
if let Some(parent) = p.parent() {
|
|
std::fs::create_dir_all(parent).unwrap();
|
|
}
|
|
let mut f = std::fs::File::create(&p).unwrap();
|
|
f.write_all(content.as_bytes()).unwrap();
|
|
}
|
|
|
|
fn make_env(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
|
let p = dir.join(name);
|
|
let mut f = std::fs::File::create(&p).unwrap();
|
|
f.write_all(content.as_bytes()).unwrap();
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_env_file_filters_correctly() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let env_path = make_env(
|
|
tmp.path(),
|
|
".env",
|
|
"# comment\n\nANTHROPIC_API_KEY=sk-ant-xxx\nlower_key=ignored\nNO_EQUALS\nQUOTED_KEY=\"value\"\n",
|
|
);
|
|
let keys = parse_env_file(&env_path).unwrap();
|
|
assert!(keys.contains(&"ANTHROPIC_API_KEY".to_string()));
|
|
assert!(keys.contains(&"QUOTED_KEY".to_string()));
|
|
assert!(!keys.contains(&"lower_key".to_string()));
|
|
assert!(!keys.contains(&"NO_EQUALS".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scan_counts_usages_correctly() {
|
|
let src_tmp = TempDir::new().unwrap();
|
|
write_file(src_tmp.path(), "main.rs", "let x = std::env::var(\"MY_KEY\").unwrap();");
|
|
write_file(src_tmp.path(), "config.toml", "key = \"$MY_KEY\"");
|
|
write_file(src_tmp.path(), "other.rs", "// no secret here");
|
|
|
|
let env_tmp = TempDir::new().unwrap();
|
|
let env_path = make_env(env_tmp.path(), ".env", "MY_KEY=secret\nORPHAN_KEY=unused\n");
|
|
|
|
let report = compute_secrets_report(&[env_path], src_tmp.path()).unwrap();
|
|
let my_key = report.keys.iter().find(|k| k.name == "MY_KEY").unwrap();
|
|
let orphan_key = report.keys.iter().find(|k| k.name == "ORPHAN_KEY").unwrap();
|
|
|
|
assert_eq!(my_key.usage_count, 2);
|
|
assert!(!my_key.orphan);
|
|
assert_eq!(orphan_key.usage_count, 0);
|
|
assert!(orphan_key.orphan);
|
|
}
|
|
|
|
#[test]
|
|
fn test_word_boundary_no_false_positive() {
|
|
let src_tmp = TempDir::new().unwrap();
|
|
// MY_KEY_EXTRA must NOT match MY_KEY due to word boundary.
|
|
write_file(src_tmp.path(), "a.rs", "let _ = std::env::var(\"MY_KEY_EXTRA\");");
|
|
|
|
let env_tmp = TempDir::new().unwrap();
|
|
let env_path = make_env(env_tmp.path(), ".env", "MY_KEY=val\n");
|
|
|
|
let report = compute_secrets_report(&[env_path], src_tmp.path()).unwrap();
|
|
let row = report.keys.iter().find(|k| k.name == "MY_KEY").unwrap();
|
|
assert_eq!(
|
|
row.usage_count, 0,
|
|
"word boundary regression: MY_KEY matched inside MY_KEY_EXTRA"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_roundtrip() {
|
|
let report = SecretsReport {
|
|
keys: vec![KeyRow {
|
|
name: "TEST_KEY".into(),
|
|
source_env_file: "/tmp/.env".into(),
|
|
usage_count: 3,
|
|
usage_files: vec!["src/main.rs".into()],
|
|
orphan: false,
|
|
}],
|
|
scanned_files: 10,
|
|
env_files: vec!["/tmp/.env".into()],
|
|
};
|
|
let json = serde_json::to_string(&report).unwrap();
|
|
let decoded: SecretsReport = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(decoded.keys[0].name, "TEST_KEY");
|
|
assert_eq!(decoded.scanned_files, 10);
|
|
assert!(!decoded.keys[0].orphan);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_ascii_shows_orphan_marker() {
|
|
let report = SecretsReport {
|
|
keys: vec![
|
|
KeyRow {
|
|
name: "ACTIVE_KEY".into(),
|
|
source_env_file: "~/.env".into(),
|
|
usage_count: 5,
|
|
usage_files: vec!["src/a.rs".into()],
|
|
orphan: false,
|
|
},
|
|
KeyRow {
|
|
name: "LEGACY_TOKEN".into(),
|
|
source_env_file: "~/.env".into(),
|
|
usage_count: 0,
|
|
usage_files: vec![],
|
|
orphan: true,
|
|
},
|
|
],
|
|
scanned_files: 20,
|
|
env_files: vec!["~/.env".into()],
|
|
};
|
|
let ascii = render_ascii(&report);
|
|
assert!(ascii.contains("*ORPHAN*"));
|
|
assert!(ascii.contains("LEGACY_TOKEN"));
|
|
assert!(ascii.contains("ACTIVE_KEY"));
|
|
assert!(ascii.contains("Total: 2 keys"));
|
|
assert!(ascii.contains("1 orphan"));
|
|
}
|