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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
/.tinyharness
/todo

.envrc
.direnv
90 changes: 60 additions & 30 deletions src/agent/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use std::io::{IsTerminal, Write};

use tinyharness_lib::SecretString;
use tinyharness_lib::config::{ProviderKind, Settings, load_settings, save_settings};
use tinyharness_ui::output::Output;
use tinyharness_ui::style::*;
Expand All @@ -30,19 +31,18 @@ pub struct SetupResult {
pub enum ApiKeyChoice {
/// Keep the existing key (or no key) as-is.
Keep,
/// Set a new key (the new value is in the `String`).
Set(String),
/// Set a new key (the new version is in the `SecretString`)
Set(SecretString),
/// Remove the existing key.
Clear,
}

/// Display a stored API key in masked form (first 4 + last 4 chars).
/// Returns "****" for short keys and "(not set)" for `None`.
pub fn mask_api_key(key: Option<&String>) -> String {
pub fn mask_api_key(key: Option<&SecretString>) -> String {
match key {
None => "(not set)".to_string(),
Some(k) if k.len() > 8 => format!("{}...{}", &k[..4], &k[k.len() - 4..]),
Some(_) => "****".to_string(),
Some(k) => k.masked(),
}
}

Expand Down Expand Up @@ -93,7 +93,7 @@ pub fn prompt_for_api_key(out: &mut Output) -> Result<ApiKeyChoice, String> {
} else if trimmed.eq_ignore_ascii_case("clear") {
Ok(ApiKeyChoice::Clear)
} else {
Ok(ApiKeyChoice::Set(trimmed.to_string()))
Ok(ApiKeyChoice::Set(SecretString::new(trimmed)))
}
}

Expand Down Expand Up @@ -258,13 +258,13 @@ pub fn save_provider_settings(kind: ProviderKind, url: &str) {
/// `cli_api_key` semantics:
/// * `None` — flag not passed; fall through to env/settings.
/// * `Some("-")` — sentinel that clears any saved key. Returns `None`.
/// * `Some("")` — explicit opt-out from auth. Returns `Some(String::new())`
/// * `Some("")` — explicit opt-out from auth. Returns `Some(SecretString::default())`
/// so the provider can be constructed without an `Authorization` header.
/// * `Some(key)` — use this key (and persist it).
///
/// Returns `None` when no key should be sent. Only the `OpenAiCompat`
/// provider uses this value; Ollama, llama.cpp, vLLM, and Sockudo ignore it.
pub fn resolve_api_key(cli_api_key: Option<&str>, settings: &Settings) -> Option<String> {
pub fn resolve_api_key(cli_api_key: Option<&str>, settings: &Settings) -> Option<SecretString> {
let result = resolve_api_key_pure(cli_api_key, settings);
// Persist side-effects only when the user actually passed `--api-key` on
// the command line (cli_api_key is Some). The pure function is used in
Expand All @@ -278,7 +278,7 @@ pub fn resolve_api_key(cli_api_key: Option<&str>, settings: &Settings) -> Option
s.openai_compat_api_key = None;
}
_ => {
s.openai_compat_api_key = Some(raw.to_string());
s.openai_compat_api_key = Some(SecretString::new(raw));
}
}
save_settings(&s);
Expand All @@ -288,17 +288,20 @@ pub fn resolve_api_key(cli_api_key: Option<&str>, settings: &Settings) -> Option

/// Pure (side-effect-free) API key resolution. Used by tests so they never
/// touch the real `~/.config/tinyharness/settings.json`.
pub fn resolve_api_key_pure(cli_api_key: Option<&str>, settings: &Settings) -> Option<String> {
pub fn resolve_api_key_pure(
cli_api_key: Option<&str>,
settings: &Settings,
) -> Option<SecretString> {
match cli_api_key {
Some("-") => return None,
Some("") => return Some(String::new()),
Some(k) => return Some(k.to_string()),
Some("") => return Some(SecretString::default()),
Some(k) => return Some(SecretString::new(k)),
None => {}
}
if let Ok(env_key) = std::env::var("OPENAI_API_KEY")
&& !env_key.is_empty()
{
return Some(env_key);
return Some(SecretString::new(env_key));
}
settings
.openai_compat_api_key
Expand Down Expand Up @@ -405,7 +408,7 @@ fn prompt_for_sockudo_credentials(out: &mut Output) -> Result<(), String> {
if trimmed.is_empty() {
s.sockudo_app_secret.clone().unwrap_or_default()
} else {
trimmed.to_string()
SecretString::new(trimmed)
}
};

Expand Down Expand Up @@ -541,12 +544,12 @@ mod tests {
fn resolve_api_key_settings_fallback() {
// When no CLI flag and no env var, fall back to settings.
let s = Settings {
openai_compat_api_key: Some("sk-settings".to_string()),
openai_compat_api_key: Some(SecretString::new("sk-settings")),
..Settings::default()
};
assert_eq!(
resolve_api_key_pure(None, &s),
Some("sk-settings".to_string())
Some(SecretString::new("sk-settings"))
);
}

Expand All @@ -559,7 +562,7 @@ mod tests {
#[test]
fn resolve_api_key_empty_string_in_settings_is_ignored() {
let s = Settings {
openai_compat_api_key: Some(String::new()),
openai_compat_api_key: Some(SecretString::default()),
..Settings::default()
};
assert_eq!(resolve_api_key_pure(None, &s), None);
Expand All @@ -569,20 +572,20 @@ mod tests {
fn resolve_api_key_cli_value_wins() {
// CLI key takes precedence over env and settings.
let s = Settings {
openai_compat_api_key: Some("sk-settings".to_string()),
openai_compat_api_key: Some(SecretString::new("sk-settings")),
..Settings::default()
};
assert_eq!(
resolve_api_key_pure(Some("sk-from-cli"), &s),
Some("sk-from-cli".to_string())
Some(SecretString::new("sk-from-cli"))
);
}

#[test]
fn resolve_api_key_clear_sentinel() {
// The sentinel "-" should return None (clear).
let s = Settings {
openai_compat_api_key: Some("sk-settings".to_string()),
openai_compat_api_key: Some(SecretString::new("sk-settings")),
..Settings::default()
};
assert_eq!(resolve_api_key_pure(Some("-"), &s), None);
Expand All @@ -593,10 +596,13 @@ mod tests {
// Explicit `--api-key ""` returns Some("") so the caller can
// construct a no-auth provider, ignoring any stored key.
let s = Settings {
openai_compat_api_key: Some("sk-settings".to_string()),
openai_compat_api_key: Some(SecretString::new("sk-settings")),
..Settings::default()
};
assert_eq!(resolve_api_key_pure(Some(""), &s), Some(String::new()));
assert_eq!(
resolve_api_key_pure(Some(""), &s),
Some(SecretString::default())
);
}

#[test]
Expand Down Expand Up @@ -630,35 +636,59 @@ mod tests {
fn mask_api_key_handles_long_keys() {
// 12-char key → first 4 + "..." + last 4
assert_eq!(
mask_api_key(Some(&"abcdef123456".to_string())),
mask_api_key(Some(&SecretString::new("abcdef123456".to_string()))),
"abcd...3456"
);
}

#[test]
fn mask_api_key_handles_short_keys() {
// 8 chars or fewer → fully masked
assert_eq!(mask_api_key(Some(&"abc".to_string())), "****");
assert_eq!(mask_api_key(Some(&"abcdefgh".to_string())), "****");
assert_eq!(
mask_api_key(Some(&SecretString::new("abc".to_string()))),
"****"
);
assert_eq!(
mask_api_key(Some(&SecretString::new("abcdefgh".to_string()))),
"****"
);
}

#[test]
fn mask_api_key_handles_exactly_9_chars() {
// 9 chars is the threshold: still show first/last 4
assert_eq!(mask_api_key(Some(&"abcdefghi".to_string())), "abcd...fghi");
assert_eq!(
mask_api_key(Some(&SecretString::new("abcdefghi".to_string()))),
"abcd...fghi"
);
}

#[test]
fn api_key_choice_equality() {
assert_eq!(ApiKeyChoice::Keep, ApiKeyChoice::Keep);
assert_ne!(ApiKeyChoice::Keep, ApiKeyChoice::Clear);
assert_eq!(
ApiKeyChoice::Set("k".to_string()),
ApiKeyChoice::Set("k".to_string())
ApiKeyChoice::Set(SecretString::new("k")),
ApiKeyChoice::Set(SecretString::new("k"))
);
assert_ne!(
ApiKeyChoice::Set("a".to_string()),
ApiKeyChoice::Set("b".to_string())
ApiKeyChoice::Set(SecretString::new("a")),
ApiKeyChoice::Set(SecretString::new("b"))
);
}

#[test]
fn api_key_choice_debug_redacts_set_value() {
// The whole point of `Set(SecretString)` is that `Debug` cannot leak
// the freshly-typed key. If a future refactor unwraps the
// `SecretString` back to `String`, this test fails loudly.
let choice = ApiKeyChoice::Set(SecretString::new("sk-supersecret"));
let dbg = format!("{:?}", choice);
assert!(dbg.contains("[REDACTED]"), "expected REDACTED in {:?}", dbg);
assert!(
!dbg.contains("sk-supersecret"),
"key leaked via Debug: {:?}",
dbg
);
}

Expand Down
10 changes: 3 additions & 7 deletions src/commands/apikey.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
use std::io::Write;

use tinyharness_lib::SecretString;
use tinyharness_lib::config::{load_settings, save_settings};
use tinyharness_ui::output::Output;

use tinyharness_ui::style::*;

pub fn execute_set(out: &mut Output, key: &str) {
let mut settings = load_settings();
settings.ollama_api_key = Some(key.to_string());
settings.ollama_api_key = Some(SecretString::new(key));
save_settings(&settings);
let _ = writeln!(out, "{BOLD}Ollama API key saved.{RESET}");
}
Expand All @@ -16,12 +17,7 @@ pub fn execute_show(out: &mut Output) {
let settings = load_settings();
match &settings.ollama_api_key {
Some(key) => {
let masked = if key.len() > 8 {
format!("{}...{}", &key[..4], &key[key.len() - 4..])
} else {
"****".to_string()
};
let _ = writeln!(out, "{BOLD}Ollama API key:{RESET} {masked}");
let _ = writeln!(out, "{BOLD}Ollama API key:{RESET} {}", key.masked());
}
None => {
let _ = writeln!(
Expand Down
11 changes: 5 additions & 6 deletions src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,11 @@ fn execute_summary(out: &mut Output, settings: &tinyharness_lib::config::Setting

match &settings.ollama_api_key {
Some(key) => {
let masked = if key.len() > 8 {
format!("{}...{}", &key[..4], &key[key.len() - 4..])
} else {
"****".to_string()
};
let _ = writeln!(out, "{BOLD}│{RESET} API Key: {BLUE}{masked}{RESET}");
let _ = writeln!(
out,
"{BOLD}│{RESET} API Key: {BLUE}{}{RESET}",
key.masked()
);
}
None => {
let _ = writeln!(out, "{BOLD}│{RESET} API Key: {ORANGE}not set{RESET}");
Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::{
};

use tinyharness_lib::{
SecretString,
config::{ProviderKind, Settings, ensure_prompts_initialized, load_settings, save_settings},
context::WorkspaceContext,
mode::AgentMode,
Expand Down Expand Up @@ -121,7 +122,7 @@ fn resolve_provider_kind(args: &Args, settings: &Settings) -> ProviderKind {
async fn create_provider(
kind: ProviderKind,
url: String,
api_key: Option<String>,
api_key: Option<SecretString>,
skip_health_check: bool,
skip_health_check_source: &str,
settings: &Settings,
Expand Down
7 changes: 4 additions & 3 deletions tinyharness-lib/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::{fmt, str::FromStr};

use serde::{Deserialize, Serialize};

use crate::SecretString;
use crate::mode::AgentMode;

// ── AutoAccept Mode ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -436,17 +437,17 @@ pub struct Settings {
#[serde(default)]
pub preferred_mode: AgentMode,
#[serde(default)]
pub ollama_api_key: Option<String>,
pub ollama_api_key: Option<SecretString>,
/// API key sent as `Authorization: Bearer <key>` by the OpenAI-compatible
/// provider (`--openai-compat`). Set via `--api-key` or `OPENAI_API_KEY`
/// env var. Not used by Ollama, llama.cpp, vLLM, or Sockudo.
pub openai_compat_api_key: Option<String>,
pub openai_compat_api_key: Option<SecretString>,
/// Sockudo app ID for the AI Transport provider.
pub sockudo_app_id: Option<String>,
/// Sockudo app key (used as auth_key in signed API requests and WebSocket URL).
pub sockudo_app_key: Option<String>,
/// Sockudo app secret (used to sign API requests via HMAC-SHA256).
pub sockudo_app_secret: Option<String>,
pub sockudo_app_secret: Option<SecretString>,
/// Timeout in seconds for Ollama requests (default: 5)
#[serde(default)]
pub ollama_timeout_secs: u64,
Expand Down
2 changes: 2 additions & 0 deletions tinyharness-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod context;
pub mod image;
pub mod mode;
pub mod provider;
pub mod secret;
pub mod session;
pub mod skill;
pub mod token;
Expand All @@ -22,6 +23,7 @@ pub use provider::{
ChatMessage, ChatMessageResponse, Message, Provider, Role, TokenUsage, ToolCall,
ToolCallFunction, ToolDefinition,
};
pub use secret::SecretString;
pub use session::{Session, SessionEntry, SessionMeta, SessionStore};
pub use skill::{Skill, SkillRegistry, SkillSource, discover_skills};
pub use token::ContextWindowSize;
Expand Down
Loading
Loading