secrets: add local key store

This commit is contained in:
Keisuke Hirata 2026-06-01 07:07:39 +09:00
parent 6e5ed683d6
commit cc2c9a2973
No known key found for this signature in database
25 changed files with 1197 additions and 151 deletions

14
Cargo.lock generated
View File

@ -2473,6 +2473,7 @@ dependencies = [
"llm-worker",
"manifest",
"reqwest",
"secrets",
"serde",
"serde_json",
"serial_test",
@ -3047,6 +3048,17 @@ version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "secrets"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@ -3783,6 +3795,7 @@ dependencies = [
"markup5ever_rcdom",
"reqwest",
"schemars",
"secrets",
"serde",
"serde_json",
"sha2 0.11.0",
@ -3932,6 +3945,7 @@ dependencies = [
"protocol",
"pulldown-cmark",
"ratatui",
"secrets",
"serde",
"serde_json",
"session-store",

View File

@ -6,6 +6,7 @@ members = [
"crates/llm-worker",
"crates/llm-worker-macros",
"crates/session-store",
"crates/secrets",
"crates/manifest",
"crates/pod",
"crates/insomnia",
@ -41,6 +42,7 @@ protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" }
session-metrics = { path = "crates/session-metrics" }
session-store = { path = "crates/session-store" }
secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" }
tui = { path = "crates/tui" }

View File

@ -15,6 +15,7 @@ enum Mode {
MemoryLintHelp,
MemoryLint(LintCliOptions),
PodRuntime(Vec<String>),
Keys,
Tui(LaunchMode),
}
@ -58,6 +59,7 @@ async fn main() -> ExitCode {
}
},
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("insomnia pod", args).await,
Mode::Keys => tui::keys::launch().await,
Mode::Tui(mode) => {
let runtime_command = match PodRuntimeCommand::resolve() {
Ok(command) => command,
@ -96,6 +98,12 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
match args[0].as_str() {
"--help" | "-h" => return Ok(Mode::Help),
"pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())),
"keys" => {
if args.len() != 1 {
return Err(ParseError("insomnia keys does not accept arguments".into()));
}
return Ok(Mode::Keys);
}
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
let lint_args = &args[2..];
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
@ -314,7 +322,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Start a fresh Pod from a profile\n -h, --help Print help\n"
"insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia keys\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Start a fresh Pod from a profile\n -h, --help Print help\n"
);
}
@ -378,6 +386,14 @@ mod tests {
}
}
#[test]
fn parse_keys_subcommand() {
match parse_args_from(["keys"]).unwrap() {
Mode::Keys => {}
_ => panic!("expected Keys mode"),
}
}
#[test]
fn parse_literal_pod_name_still_available_with_flag() {
match parse_args_from(["--pod", "pod"]).unwrap() {

View File

@ -331,7 +331,7 @@ impl crate::WebSearchConfig {
Self {
enabled: upper.enabled.or(self.enabled),
provider: upper.provider.or(self.provider),
api_key_env: upper.api_key_env.or(self.api_key_env),
api_key_secret: upper.api_key_secret.or(self.api_key_secret),
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
base_url: upper.base_url.or(self.base_url),
country: upper.country.or(self.country),
@ -517,7 +517,7 @@ fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError>
}
}
/// `AuthRef::ApiKey { file, .. }` が相対パスのとき `base` を前置する。
/// `AuthRef::ApiKey { file }` が相対パスのとき `base` を前置する。
fn resolve_auth_file(auth: &mut Option<AuthRef>, base: &Path) {
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
*p = join_if_relative(base, p);
@ -692,10 +692,7 @@ mod tests {
}
fn api_key_file_auth(path: PathBuf) -> AuthRef {
AuthRef::ApiKey {
env: None,
file: Some(path),
}
AuthRef::ApiKey { file: Some(path) }
}
fn minimal_valid() -> PodManifestConfig {
@ -1089,7 +1086,7 @@ mod tests {
}),
web: Some(WebConfig {
search: Some(crate::WebSearchConfig {
api_key_env: Some("LOWER_BRAVE_KEY".into()),
api_key_secret: Some("web/brave/lower".into()),
timeout_secs: Some(12),
..Default::default()
}),
@ -1118,7 +1115,7 @@ mod tests {
assert_eq!(c.prune_protected_tokens, Some(5_000));
let search = merged.web.unwrap().search.unwrap();
assert_eq!(search.timeout_secs, Some(3));
assert_eq!(search.api_key_env.as_deref(), Some("LOWER_BRAVE_KEY"));
assert_eq!(search.api_key_secret.as_deref(), Some("web/brave/lower"));
}
#[test]

View File

@ -122,10 +122,10 @@ pub struct WebSearchConfig {
pub enabled: Option<bool>,
#[serde(default)]
pub provider: Option<WebSearchProvider>,
/// Environment variable that stores the provider API key. Raw secrets do
/// not belong in manifest files.
/// Local secret-store id for the provider API key. Raw secrets do not
/// belong in manifest files.
#[serde(default)]
pub api_key_env: Option<String>,
pub api_key_secret: Option<String>,
/// Request timeout in seconds. Tool implementation applies a safe default
/// when this is omitted.
#[serde(default)]
@ -651,7 +651,7 @@ permission = "write"
#[test]
fn parse_web_config() {
let toml = format!(
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_env = \"BRAVE_SEARCH_API_KEY\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
MINIMAL_REQUIRED
);
let manifest = PodManifest::from_toml(&toml).unwrap();

View File

@ -97,7 +97,7 @@ pub enum SchemeKind {
/// 認証の参照。
///
/// 実際のトークン値の解決(env / file 読取、OAuth refresh 等)は
/// 実際のトークン値の解決(local secret store / file 読取、OAuth refresh 等)は
/// `crates/provider` で行う。ここはあくまで「どこから取るか」の宣言。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
@ -105,11 +105,10 @@ pub enum AuthRef {
/// 認証不要(ローカル Ollama 等)
#[default]
None,
/// API key。env / file のいずれか(両方指定された場合は env が優先)
/// API key file reference. Prefer [`AuthRef::SecretRef`] for normal
/// provider credentials; this remains an explicit file source for low-level
/// manifests and tests.
ApiKey {
/// 環境変数名。未指定のときは scheme ごとの既定(`INSOMNIA_API_KEY_*`
#[serde(default)]
env: Option<String>,
/// key を書き込んだファイル(絶対パス)
#[serde(default)]
file: Option<PathBuf>,
@ -117,8 +116,8 @@ pub enum AuthRef {
/// ChatGPT OAuth`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
#[serde(rename = "codex_oauth")]
CodexOAuth,
/// Typed secret-store reference. The profile resolver preserves this
/// reference verbatim; secret-store lookup/decryption is intentionally a
/// Typed local secret-store reference. The profile resolver preserves this
/// reference verbatim; secret-store lookup/deobfuscation is intentionally a
/// later consumer-boundary concern.
#[serde(rename = "secret_ref")]
SecretRef {
@ -126,16 +125,3 @@ pub enum AuthRef {
ref_: String,
},
}
impl SchemeKind {
/// 既定の環境変数名(`INSOMNIA_API_KEY_*`)。
///
/// `AuthRef::ApiKey { env: None, .. }` の env 未指定時に使う。
pub fn default_env_var(self) -> &'static str {
match self {
Self::Anthropic => "INSOMNIA_API_KEY_ANTHROPIC",
Self::OpenaiChat | Self::OpenaiResponses => "INSOMNIA_API_KEY_OPENAI",
Self::Gemini => "INSOMNIA_API_KEY_GEMINI",
}
}
}

View File

@ -1038,9 +1038,7 @@ fn reject_absolute_auth_file(
auth: &Option<AuthRef>,
field: &'static str,
) -> Result<(), ProfileError> {
if let Some(AuthRef::ApiKey {
file: Some(file), ..
}) = auth
if let Some(AuthRef::ApiKey { file: Some(file) }) = auth
&& file.is_absolute()
{
return Err(ProfileError::InvalidProfile(format!(

View File

@ -991,7 +991,6 @@ return profile {
base_url: Some("https://example.test".into()),
model_id: Some("claude-sonnet-4".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/etc/keys/anthropic")),
}),
..Default::default()

View File

@ -10,6 +10,7 @@ base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
llm-worker = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
reqwest = { version = "0.13", features = ["json", "native-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@ -1,6 +1,6 @@
# provider
マニフェストの `ModelManifest` から適切な `LlmClient``HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの環境変数 / ファイル解決、scheme ↔ auth の整合検証を担う。
マニフェストの `ModelManifest` から適切な `LlmClient``HttpTransport<S>`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの local secret store / 明示ファイル解決、scheme ↔ auth の整合検証を担う。
## 公開型
@ -14,7 +14,7 @@
- プロバイダ / モデルカタログの builtin (`resources/{providers,models}/builtin.toml`) と user override (`$XDG_CONFIG_HOME/insomnia/{providers,models}.toml`) の解決
- `ModelManifest` の ref 形を `(provider, model_id)` に split し、`ModelConfig` へ展開
- `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(env → file の優先順位
- `AuthRef::SecretRef` / `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(通常は local secret store、低レベル manifest では明示ファイルも可
- `AuthRef::None` / `AuthRef::CodexOAuth` の解決
- `Scheme::required_auth()``ResolvedAuth` の妥当性検証(非対応組合せは構築エラー)
- capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決

View File

@ -72,10 +72,15 @@ pub enum ResolveError {
pub enum AuthHint {
/// 認証不要(ローカル Ollama 等)
None,
/// API key。`env` が指定されていれば UI はその env 名を提示する
ApiKey {
#[serde(default)]
env: Option<String>,
/// API key file reference. Normal credential configuration should prefer
/// [`AuthHint::SecretRef`] so plaintext credentials stay out of manifests.
ApiKey,
/// Local secret-store reference. The catalog/profile explicitly chooses the
/// logical key id; the secret store itself has no provider semantics.
#[serde(rename = "secret_ref")]
SecretRef {
#[serde(rename = "ref")]
ref_: String,
},
/// ChatGPT OAuth`~/.codex/auth.json`
#[serde(rename = "codex_oauth")]
@ -153,16 +158,12 @@ struct ModelCatalogFile {
model: Vec<ModelEntry>,
}
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。env / file は
/// マニフェスト側で override 可能なので、ここでは hint そのままを
/// 反映した最小形だけを返す(`AuthRef::ApiKey { env: hint_env, file: None }`)。
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。
fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef {
match hint {
AuthHint::None => AuthRef::None,
AuthHint::ApiKey { env } => AuthRef::ApiKey {
env: env.clone(),
file: None,
},
AuthHint::ApiKey => AuthRef::ApiKey { file: None },
AuthHint::SecretRef { ref_ } => AuthRef::SecretRef { ref_: ref_.clone() },
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
}
}
@ -415,11 +416,10 @@ mod tests {
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com"));
match cfg.auth {
AuthRef::ApiKey { env, file } => {
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC"));
assert!(file.is_none());
AuthRef::SecretRef { ref_ } => {
assert_eq!(ref_, "providers/anthropic/default");
}
_ => panic!("expected ApiKey auth from provider hint"),
_ => panic!("expected SecretRef auth from provider hint"),
}
assert!(
cfg.capability.is_some(),
@ -493,15 +493,13 @@ mod tests {
let manifest = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/tmp/sk-ant")),
}),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
match cfg.auth {
AuthRef::ApiKey { env, file } => {
assert!(env.is_none());
AuthRef::ApiKey { file } => {
assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant")));
}
_ => panic!("override auth should win"),
@ -555,7 +553,6 @@ mod tests {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-6".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/tmp/sk")),
}),
..Default::default()
@ -575,7 +572,6 @@ mod tests {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("claude-sonnet-4-6".into()),
auth: Some(AuthRef::ApiKey {
env: None,
file: Some(PathBuf::from("/tmp/sk")),
}),
context_window: Some(777_000),

View File

@ -4,7 +4,7 @@
//! 段階:
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
//! カタログ込み [`ModelConfig`] に解決ref → 展開 / inline → 検証)
//! 2. `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に
//! 2. `AuthRef` を local secret store / ファイルから解決して [`ResolvedAuth`] に
//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー)
//! 4. `ModelCapability` は manifest 明示 > model catalog > provider
//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は
@ -30,6 +30,7 @@ use llm_worker::llm_client::{
};
use manifest::{AuthRef, ModelManifest, SchemeKind};
use secrets::{SecretStore, SecretValue};
pub use catalog::{ModelConfig, ResolveError as CatalogResolveError};
@ -42,6 +43,13 @@ pub enum ProviderError {
#[error("API key not provided for scheme {scheme:?}")]
ApiKeyMissing { scheme: SchemeKind },
#[error("failed to resolve secret `{id}`: {source}")]
SecretStore {
id: String,
#[source]
source: secrets::Error,
},
#[error("scheme {scheme:?} does not support this auth")]
AuthMismatch { scheme: SchemeKind },
@ -53,21 +61,38 @@ pub enum ProviderError {
}
/// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。
///
/// 解決順:
/// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照
/// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`)
/// 3. それでも無ければ `file` を読む(絶対パスのみ)
fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, ProviderError> {
let resolver = DefaultSecretResolver;
resolve_auth_with_resolver(scheme, auth, &resolver)
}
trait SecretResolver {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error>;
}
struct DefaultSecretResolver;
impl SecretResolver for DefaultSecretResolver {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
let data_dir = manifest::paths::data_dir().ok_or_else(|| secrets::Error::Read {
path: std::path::PathBuf::from("<data_dir>"),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not determine insomnia data directory",
),
})?;
SecretStore::new(data_dir).get(id)
}
}
fn resolve_auth_with_resolver(
scheme: SchemeKind,
auth: &AuthRef,
resolver: &dyn SecretResolver,
) -> Result<ResolvedAuth, ProviderError> {
match auth {
AuthRef::None => Ok(ResolvedAuth::None),
AuthRef::ApiKey { env, file } => {
let env_name = env.as_deref().unwrap_or(scheme.default_env_var());
if let Ok(val) = std::env::var(env_name)
&& !val.is_empty()
{
return Ok(ResolvedAuth::ApiKey(val));
}
AuthRef::ApiKey { file } => {
if let Some(path) = file {
if !path.is_absolute() {
return Err(ProviderError::Config(format!(
@ -90,9 +115,15 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, Prov
.map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider)))
}
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
"secret store references are not implemented yet: {ref_}"
))),
AuthRef::SecretRef { ref_ } => {
let value = resolver
.get_secret(ref_)
.map_err(|source| ProviderError::SecretStore {
id: ref_.clone(),
source,
})?;
Ok(ResolvedAuth::ApiKey(value.into_string()))
}
}
}
@ -179,15 +210,24 @@ mod tests {
use std::io::Write;
use std::path::PathBuf;
struct TestSecrets(std::collections::BTreeMap<String, String>);
impl SecretResolver for TestSecrets {
fn get_secret(&self, id: &str) -> Result<SecretValue, secrets::Error> {
self.0
.get(id)
.cloned()
.map(SecretValue::new)
.ok_or_else(|| secrets::Error::NotFound { id: id.to_string() })
}
}
fn anthropic_config() -> ModelConfig {
ModelConfig {
scheme: SchemeKind::Anthropic,
base_url: None,
model_id: "claude-sonnet-4-20250514".into(),
auth: AuthRef::ApiKey {
env: None,
file: None,
},
auth: AuthRef::ApiKey { file: None },
capability: None,
context_window: 200_000,
max_context_window: None,
@ -195,18 +235,41 @@ mod tests {
}
#[test]
#[serial]
fn resolve_from_env() {
let env_name = SchemeKind::Anthropic.default_env_var();
unsafe { std::env::set_var(env_name, "sk-from-env") };
let auth = resolve_auth(SchemeKind::Anthropic, &anthropic_config().auth).unwrap();
unsafe { std::env::remove_var(env_name) };
fn resolve_from_secret_ref() {
let resolver = TestSecrets(std::collections::BTreeMap::from([(
"providers/anthropic/default".to_string(),
"sk-from-store".to_string(),
)]));
let auth = resolve_auth_with_resolver(
SchemeKind::Anthropic,
&AuthRef::SecretRef {
ref_: "providers/anthropic/default".into(),
},
&resolver,
)
.unwrap();
match auth {
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-store"),
_ => panic!("expected ApiKey"),
}
}
#[test]
fn missing_secret_names_only_id() {
let resolver = TestSecrets(Default::default());
let err = resolve_auth_with_resolver(
SchemeKind::Anthropic,
&AuthRef::SecretRef {
ref_: "providers/anthropic/missing".into(),
},
&resolver,
)
.unwrap_err();
let message = err.to_string();
assert!(message.contains("providers/anthropic/missing"));
assert!(!message.contains("sk-"));
}
#[test]
fn resolve_from_file() {
let dir = tempfile::tempdir().unwrap();
@ -217,7 +280,6 @@ mod tests {
}
let config = ModelConfig {
auth: AuthRef::ApiKey {
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
file: Some(key_path),
},
..anthropic_config()
@ -229,36 +291,10 @@ mod tests {
}
}
#[test]
#[serial]
fn env_takes_precedence_over_file() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("key.txt");
std::fs::write(&key_path, "sk-from-file").unwrap();
let env_name = SchemeKind::Anthropic.default_env_var();
unsafe { std::env::set_var(env_name, "sk-from-env") };
let config = ModelConfig {
auth: AuthRef::ApiKey {
env: None,
file: Some(key_path),
},
..anthropic_config()
};
let auth = resolve_auth(config.scheme, &config.auth).unwrap();
unsafe { std::env::remove_var(env_name) };
match auth {
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
_ => panic!("expected ApiKey"),
}
}
#[test]
fn relative_auth_file_is_rejected() {
let config = ModelConfig {
auth: AuthRef::ApiKey {
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
file: Some(PathBuf::from("keys/anthropic")),
},
..anthropic_config()
@ -270,8 +306,6 @@ mod tests {
#[test]
#[serial]
fn missing_key_returns_api_key_missing() {
let env_name = SchemeKind::Anthropic.default_env_var();
unsafe { std::env::remove_var(env_name) };
let result = build_client_from_config(&anthropic_config());
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
}

14
crates/secrets/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "secrets"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

493
crates/secrets/src/lib.rs Normal file
View File

@ -0,0 +1,493 @@
use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const STORE_VERSION: u32 = 1;
const KEY_LEN: usize = 32;
const TAG_LEN: usize = 32;
const MAX_ID_LEN: usize = 128;
static NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("secret id is empty")]
EmptyId,
#[error("secret id `{id}` is too long (max {max} bytes)")]
IdTooLong { id: String, max: usize },
#[error("secret id `{0}` contains unsupported characters")]
UnsupportedIdChars(String),
#[error("secret id `{0}` must not be absolute or contain traversal components")]
UnsafeId(String),
#[error("failed to read secret store {}: {source}", .path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse secret store {}: {source}", .path.display())]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("unsupported secret store version {version} in {}", .path.display())]
UnsupportedVersion { path: PathBuf, version: u32 },
#[error("failed to decode secret `{id}`")]
Decode { id: String },
#[error("secret `{id}` was not found")]
NotFound { id: String },
#[error("failed to create secret store directory {}: {source}", .path.display())]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write secret store {}: {source}", .path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct SecretId(String);
impl SecretId {
pub fn parse(value: impl Into<String>) -> Result<Self> {
let value = value.into();
validate_id(&value)?;
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("SecretId").field(&self.0).finish()
}
}
impl fmt::Display for SecretId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct SecretValue(String);
impl SecretValue {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Debug for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretValue([redacted])")
}
}
#[derive(Debug, Clone)]
pub struct SecretStore {
path: PathBuf,
key: [u8; KEY_LEN],
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct StoreFile {
version: u32,
#[serde(default)]
entries: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
nonce: String,
ciphertext: String,
tag: String,
}
impl SecretStore {
pub fn new(data_dir: impl AsRef<Path>) -> Self {
let data_dir = data_dir.as_ref();
let path = data_dir.join("secrets").join("store.json");
Self::at_path_with_key(path, derive_key(data_dir))
}
pub fn at_path_for_tests(path: impl AsRef<Path>) -> Self {
let path = path.as_ref().to_path_buf();
Self::at_path_with_key(
path.clone(),
derive_key(path.parent().unwrap_or(Path::new(""))),
)
}
pub fn at_path_with_key(path: PathBuf, key: [u8; KEY_LEN]) -> Self {
Self { path, key }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list_ids(&self) -> Result<Vec<SecretId>> {
let file = self.load()?;
file.entries.into_keys().map(SecretId::parse).collect()
}
pub fn get(&self, id: &str) -> Result<SecretValue> {
let id = SecretId::parse(id.to_string())?;
let file = self.load()?;
let entry = file
.entries
.get(id.as_str())
.ok_or_else(|| Error::NotFound { id: id.to_string() })?;
let plaintext = decrypt_entry(&self.key, &id, entry)?;
Ok(SecretValue::new(
String::from_utf8(plaintext).map_err(|_| Error::Decode { id: id.to_string() })?,
))
}
pub fn set(&self, id: &str, value: SecretValue) -> Result<()> {
let id = SecretId::parse(id.to_string())?;
let mut file = self.load()?;
file.entries.insert(
id.to_string(),
encrypt_entry(&self.key, &id, value.expose_secret().as_bytes()),
);
self.save(&file)
}
pub fn delete(&self, id: &str) -> Result<bool> {
let id = SecretId::parse(id.to_string())?;
let mut file = self.load()?;
let removed = file.entries.remove(id.as_str()).is_some();
if removed {
self.save(&file)?;
}
Ok(removed)
}
fn load(&self) -> Result<StoreFile> {
match fs::read_to_string(&self.path) {
Ok(text) => {
let file: StoreFile =
serde_json::from_str(&text).map_err(|source| Error::Parse {
path: self.path.clone(),
source,
})?;
if file.version != STORE_VERSION {
return Err(Error::UnsupportedVersion {
path: self.path.clone(),
version: file.version,
});
}
Ok(file)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoreFile {
version: STORE_VERSION,
entries: BTreeMap::new(),
}),
Err(source) => Err(Error::Read {
path: self.path.clone(),
source,
}),
}
}
fn save(&self, file: &StoreFile) -> Result<()> {
let parent = self.path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent).map_err(|source| Error::CreateDir {
path: parent.to_path_buf(),
source,
})?;
let data = serde_json::to_vec_pretty(file).map_err(|source| Error::Write {
path: self.path.clone(),
source: std::io::Error::other(source),
})?;
let tmp = self.temp_path();
{
let mut fh = OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.map_err(|source| Error::Write {
path: tmp.clone(),
source,
})?;
fh.write_all(&data).map_err(|source| Error::Write {
path: tmp.clone(),
source,
})?;
fh.sync_all().map_err(|source| Error::Write {
path: tmp.clone(),
source,
})?;
}
fs::rename(&tmp, &self.path).map_err(|source| Error::Write {
path: self.path.clone(),
source,
})?;
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
fn temp_path(&self) -> PathBuf {
let suffix = NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let file_name = self
.path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("store.json");
self.path.with_file_name(format!(
".{file_name}.{}.{}.{}.tmp",
std::process::id(),
now,
suffix
))
}
}
pub fn validate_id(id: &str) -> Result<()> {
if id.is_empty() {
return Err(Error::EmptyId);
}
if id.len() > MAX_ID_LEN {
return Err(Error::IdTooLong {
id: id.to_string(),
max: MAX_ID_LEN,
});
}
if id.starts_with('/') || id.starts_with('~') || id.contains("//") {
return Err(Error::UnsafeId(id.to_string()));
}
for component in id.split('/') {
if component.is_empty() || component == "." || component == ".." {
return Err(Error::UnsafeId(id.to_string()));
}
}
if !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
{
return Err(Error::UnsupportedIdChars(id.to_string()));
}
Ok(())
}
fn derive_key(data_dir: &Path) -> [u8; KEY_LEN] {
let mut hasher = Sha256::new();
hasher.update(b"insomnia local secret store obfuscation key v1");
hasher.update(data_dir.as_os_str().as_encoded_bytes());
hasher.finalize().into()
}
fn encrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, plaintext: &[u8]) -> Entry {
let nonce = make_nonce(id.as_str(), plaintext);
let ciphertext = xor_stream(key, &nonce, plaintext);
let tag = tag(key, id.as_str(), &nonce, &ciphertext);
Entry {
nonce: hex_encode(&nonce),
ciphertext: hex_encode(&ciphertext),
tag: hex_encode(&tag),
}
}
fn decrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, entry: &Entry) -> Result<Vec<u8>> {
let nonce = hex_decode(&entry.nonce).map_err(|_| Error::Decode { id: id.to_string() })?;
let ciphertext =
hex_decode(&entry.ciphertext).map_err(|_| Error::Decode { id: id.to_string() })?;
let actual_tag = hex_decode(&entry.tag).map_err(|_| Error::Decode { id: id.to_string() })?;
let expected = tag(key, id.as_str(), &nonce, &ciphertext);
if actual_tag.as_slice() != expected {
return Err(Error::Decode { id: id.to_string() });
}
Ok(xor_stream(key, &nonce, &ciphertext))
}
fn make_nonce(id: &str, plaintext: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
hasher.update(b"insomnia nonce v1");
hasher.update(now.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
hasher.update(NONCE_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
hasher.update(id.as_bytes());
hasher.update(plaintext);
hasher.finalize()[..16].to_vec()
}
fn xor_stream(key: &[u8; KEY_LEN], nonce: &[u8], input: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(input.len());
let mut counter = 0u64;
for chunk in input.chunks(KEY_LEN) {
let mut hasher = Sha256::new();
hasher.update(b"insomnia secret keystream v1");
hasher.update(key);
hasher.update(nonce);
hasher.update(counter.to_le_bytes());
let block = hasher.finalize();
for (b, k) in chunk.iter().zip(block.iter()) {
out.push(b ^ k);
}
counter += 1;
}
out
}
fn tag(key: &[u8; KEY_LEN], id: &str, nonce: &[u8], ciphertext: &[u8]) -> [u8; TAG_LEN] {
let mut hasher = Sha256::new();
hasher.update(b"insomnia secret tag v1");
hasher.update(key);
hasher.update(id.as_bytes());
hasher.update(nonce);
hasher.update(ciphertext);
hasher.finalize().into()
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn hex_decode(s: &str) -> std::result::Result<Vec<u8>, ()> {
if !s.len().is_multiple_of(2) {
return Err(());
}
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
for pair in bytes.chunks_exact(2) {
let high = hex_value(pair[0])?;
let low = hex_value(pair[1])?;
out.push((high << 4) | low);
}
Ok(out)
}
fn hex_value(b: u8) -> std::result::Result<u8, ()> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_store() -> (tempfile::TempDir, SecretStore) {
let dir = tempfile::tempdir().unwrap();
let store =
SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [7u8; KEY_LEN]);
(dir, store)
}
#[test]
fn roundtrip_list_delete() {
let (_dir, store) = test_store();
store
.set("anthropic/default", SecretValue::new("sk-test-secret"))
.unwrap();
assert_eq!(
store.get("anthropic/default").unwrap().expose_secret(),
"sk-test-secret"
);
assert_eq!(store.list_ids().unwrap()[0].as_str(), "anthropic/default");
assert!(store.delete("anthropic/default").unwrap());
assert!(matches!(
store.get("anthropic/default"),
Err(Error::NotFound { .. })
));
}
#[test]
fn invalid_ids_are_rejected() {
for id in ["", "/abs", "../x", "x/../y", "x//y", "x y", "x\ny", "~home"] {
assert!(SecretId::parse(id).is_err(), "{id:?} should be invalid");
}
assert!(SecretId::parse("a".repeat(MAX_ID_LEN + 1)).is_err());
assert!(SecretId::parse("web/brave.default-1").is_ok());
}
#[test]
fn corrupted_store_fails_closed() {
let (dir, store) = test_store();
store
.set("web/brave", SecretValue::new("secret-value"))
.unwrap();
let path = dir.path().join("secrets/store.json");
let mut file: StoreFile =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let entry = file.entries.get_mut("web/brave").unwrap();
let replacement = if entry.ciphertext.starts_with('0') {
"1"
} else {
"0"
};
entry.ciphertext.replace_range(0..1, replacement);
fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
assert!(matches!(store.get("web/brave"), Err(Error::Decode { id }) if id == "web/brave"));
}
#[test]
fn plaintext_is_not_written_to_disk_or_debug() {
let (dir, store) = test_store();
let value = SecretValue::new("sk-plain-must-not-appear");
assert!(!format!("{value:?}").contains("sk-plain"));
store.set("provider/test", value).unwrap();
let text = fs::read_to_string(dir.path().join("secrets/store.json")).unwrap();
assert!(!text.contains("sk-plain-must-not-appear"));
assert!(text.contains("provider/test"));
}
#[test]
fn wrong_key_or_tamper_fails_decode() {
let (dir, store) = test_store();
store
.set("provider/test", SecretValue::new("secret-value"))
.unwrap();
let wrong =
SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [9u8; KEY_LEN]);
assert!(
matches!(wrong.get("provider/test"), Err(Error::Decode { id }) if id == "provider/test")
);
}
}

View File

@ -14,6 +14,7 @@ ignore = "0.4.25"
html5ever = "0.26"
llm-worker = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
markup5ever_rcdom = "0.2"
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
schemars = { workspace = true }

View File

@ -12,6 +12,7 @@ use markup5ever_rcdom::{Handle, NodeData, RcDom};
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, LOCATION};
use reqwest::{Client, Url};
use schemars::JsonSchema;
use secrets::SecretStore;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::net::lookup_host;
@ -36,6 +37,7 @@ const WEB_FETCH_TRUNCATION_MARKER: &str = "\n[truncated]";
pub struct WebTools {
config: Option<WebConfig>,
client: Client,
secret_store: Option<SecretStore>,
}
impl WebTools {
@ -45,7 +47,25 @@ impl WebTools {
.user_agent("insomnia-web-tools/0.1")
.build()
.expect("static reqwest client configuration is valid");
Self { config, client }
let secret_store = manifest::paths::data_dir().map(SecretStore::new);
Self {
config,
client,
secret_store,
}
}
#[cfg(test)]
fn with_client_and_secret_store(
config: Option<WebConfig>,
client: Client,
secret_store: Option<SecretStore>,
) -> Self {
Self {
config,
client,
secret_store,
}
}
fn global_enabled(&self) -> bool {
@ -153,11 +173,19 @@ impl WebTools {
match cfg.provider.ok_or_else(|| {
disabled_error(
"WebSearch",
"set web.search.provider = \"brave\" and web.search.api_key_env",
"set web.search.provider = \"brave\" and web.search.api_key_secret",
)
})? {
WebSearchProvider::Brave => {
brave_search(&self.client, cfg, &input.query, limit, offset).await
brave_search(
&self.client,
cfg,
self.secret_store.as_ref(),
&input.query,
limit,
offset,
)
.await
}
}
}
@ -213,28 +241,35 @@ pub fn web_fetch_tool(tools: WebTools) -> ToolDefinition {
async fn brave_search(
client: &Client,
cfg: &WebSearchConfig,
secret_store: Option<&SecretStore>,
query: &str,
limit: usize,
offset: usize,
) -> Result<ToolOutput, ToolError> {
let api_key_env = cfg.api_key_env.as_ref().ok_or_else(|| {
let api_key_secret = cfg.api_key_secret.as_ref().ok_or_else(|| {
disabled_error(
"WebSearch",
"set web.search.api_key_env to an environment variable containing the Brave API key",
"set web.search.api_key_secret to the insomnia keys secret id for the Brave API key",
)
})?;
let api_key = std::env::var(api_key_env).map_err(|_| {
let store = secret_store.ok_or_else(|| {
ToolError::ExecutionFailed(
"WebSearch provider is configured but the local secret store path is unavailable"
.into(),
)
})?;
let api_key = store.get(api_key_secret).map_err(|err| {
ToolError::ExecutionFailed(format!(
"WebSearch provider is configured but environment variable {api_key_env} is not set"
"WebSearch provider is configured but secret `{api_key_secret}` could not be resolved: {err}"
))
})?;
if api_key.trim().is_empty() {
if api_key.expose_secret().trim().is_empty() {
return Err(ToolError::ExecutionFailed(format!(
"WebSearch provider is configured but environment variable {api_key_env} is empty"
"WebSearch provider is configured but secret `{api_key_secret}` is empty"
)));
}
brave_search_with_api_key(client, cfg, &api_key, query, limit, offset).await
brave_search_with_api_key(client, cfg, api_key.expose_secret(), query, limit, offset).await
}
async fn brave_search_with_api_key(
@ -1709,7 +1744,7 @@ mod tests {
WebSearchConfig {
enabled: Some(true),
provider: Some(WebSearchProvider::Brave),
api_key_env: None,
api_key_secret: Some("web/brave/test".into()),
timeout_secs: Some(2),
base_url: Some(base_url),
..Default::default()
@ -2037,6 +2072,49 @@ mod tests {
assert_eq!(value.get("redirects").unwrap().as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn searches_brave_with_secret_ref() {
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\"}]}}";
let (addr, captured) = serve_once_capture(response).await;
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::at_path_for_tests(dir.path().join("secrets/store.json"));
store
.set(
"web/brave/test",
secrets::SecretValue::new("test-secret-ref"),
)
.unwrap();
let tools = WebTools::with_client_and_secret_store(
Some(WebConfig {
enabled: Some(true),
allow_private_addresses: Some(true),
search: Some(brave_search_config(format!("http://{addr}/search"))),
fetch: None,
}),
Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap(),
Some(store),
);
let result = tools
.run_search(WebSearchInput {
query: "insomnia".into(),
limit: Some(1),
offset: Some(0),
})
.await
.unwrap();
let value: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap();
let request = captured.lock().await.clone().unwrap();
assert!(
request
.to_ascii_lowercase()
.contains("x-subscription-token: test-secret-ref\r\n")
);
assert_eq!(value["results"][0]["title"], "Example");
}
#[tokio::test]
async fn searches_brave_with_bounded_output() {
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\",\"extra_snippets\":[\"Extra\"],\"language\":\"en\"}]}}";

View File

@ -15,6 +15,7 @@ unicode-width = "0.2.2"
uuid = { workspace = true }
toml = { workspace = true }
manifest = { workspace = true }
secrets = { workspace = true }
session-store = { workspace = true }
pod-store = { workspace = true }
pod-registry = { workspace = true }

406
crates/tui/src/keys.rs Normal file
View File

@ -0,0 +1,406 @@
use std::io::{self, Stdout};
use std::process::ExitCode;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use secrets::{SecretStore, SecretValue};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
Normal,
EditingId,
EditingValue { id: String },
ConfirmDelete { id: String },
}
#[derive(Clone)]
struct KeysApp {
ids: Vec<String>,
selected: usize,
mode: Mode,
input: String,
notice: String,
quit: bool,
}
impl std::fmt::Debug for KeysApp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let input = match self.mode {
Mode::EditingValue { .. } => "[redacted]".to_string(),
_ => self.input.clone(),
};
f.debug_struct("KeysApp")
.field("ids", &self.ids)
.field("selected", &self.selected)
.field("mode", &self.mode)
.field("input", &input)
.field("notice", &self.notice)
.field("quit", &self.quit)
.finish()
}
}
impl KeysApp {
fn new(ids: Vec<String>) -> Self {
let mut app = Self {
ids,
selected: 0,
mode: Mode::Normal,
input: String::new(),
notice: String::new(),
quit: false,
};
app.clamp_selection();
app
}
fn refresh(&mut self, ids: Vec<String>) {
self.ids = ids;
self.clamp_selection();
}
fn selected_id(&self) -> Option<&str> {
self.ids.get(self.selected).map(String::as_str)
}
fn clamp_selection(&mut self) {
if self.ids.is_empty() {
self.selected = 0;
} else if self.selected >= self.ids.len() {
self.selected = self.ids.len() - 1;
}
}
fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Action {
if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') {
self.quit = true;
return Action::Quit;
}
match self.mode.clone() {
Mode::Normal => self.handle_normal(code),
Mode::EditingId => self.handle_editing_id(code),
Mode::EditingValue { id } => self.handle_editing_value(code, id),
Mode::ConfirmDelete { id } => self.handle_confirm_delete(code, id),
}
}
fn handle_normal(&mut self, code: KeyCode) -> Action {
match code {
KeyCode::Char('q') | KeyCode::Esc => {
self.quit = true;
Action::Quit
}
KeyCode::Char('a') => {
self.input.clear();
self.notice = "Enter secret id, then Enter".into();
self.mode = Mode::EditingId;
Action::None
}
KeyCode::Char('d') => {
if let Some(id) = self.selected_id() {
self.mode = Mode::ConfirmDelete { id: id.to_string() };
self.notice = "Delete selected secret? y/N".into();
} else {
self.notice = "No key selected".into();
}
Action::None
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.ids.is_empty() {
self.selected = (self.selected + 1).min(self.ids.len() - 1);
}
Action::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
Action::None
}
_ => Action::None,
}
}
fn handle_editing_id(&mut self, code: KeyCode) -> Action {
match code {
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
self.notice = "Add cancelled".into();
Action::None
}
KeyCode::Enter => {
let id = self.input.trim().to_string();
if id.is_empty() {
self.notice = "Secret id must not be empty".into();
return Action::None;
}
self.input.clear();
self.notice = "Enter secret value; input is masked".into();
self.mode = Mode::EditingValue { id };
Action::None
}
KeyCode::Backspace => {
self.input.pop();
Action::None
}
KeyCode::Char(c) if !c.is_control() => {
self.input.push(c);
Action::None
}
_ => Action::None,
}
}
fn handle_editing_value(&mut self, code: KeyCode, id: String) -> Action {
match code {
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input.clear();
self.notice = "Add cancelled".into();
Action::None
}
KeyCode::Enter => {
let value = std::mem::take(&mut self.input);
self.mode = Mode::Normal;
Action::Set { id, value }
}
KeyCode::Backspace => {
self.input.pop();
Action::None
}
KeyCode::Char(c) if !c.is_control() => {
self.input.push(c);
Action::None
}
_ => Action::None,
}
}
fn handle_confirm_delete(&mut self, code: KeyCode, id: String) -> Action {
self.mode = Mode::Normal;
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => Action::Delete { id },
_ => {
self.notice = "Delete cancelled".into();
Action::None
}
}
}
}
#[derive(Clone, PartialEq, Eq)]
enum Action {
None,
Set { id: String, value: String },
Delete { id: String },
Quit,
}
impl std::fmt::Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => f.write_str("None"),
Self::Set { id, .. } => f
.debug_struct("Set")
.field("id", id)
.field("value", &"[redacted]")
.finish(),
Self::Delete { id } => f.debug_struct("Delete").field("id", id).finish(),
Self::Quit => f.write_str("Quit"),
}
}
}
pub async fn launch() -> ExitCode {
let data_dir = match manifest::paths::data_dir() {
Some(path) => path,
None => {
eprintln!("insomnia keys: could not determine insomnia data directory");
return ExitCode::FAILURE;
}
};
match run(SecretStore::new(data_dir)) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("insomnia keys: {err}");
ExitCode::FAILURE
}
}
}
type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
fn run(store: SecretStore) -> UiResult<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, crossterm::cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(&mut terminal, store);
let mut stdout = io::stdout();
let _ = execute!(stdout, crossterm::cursor::Show, LeaveAlternateScreen);
let _ = disable_raw_mode();
result
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, store: SecretStore) -> UiResult<()> {
let mut app = KeysApp::new(load_ids(&store)?);
loop {
terminal.draw(|frame| draw(frame, &app))?;
if app.quit {
return Ok(());
}
if event::poll(Duration::from_millis(200))? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match app.handle_key(key.code, key.modifiers) {
Action::None => {}
Action::Quit => return Ok(()),
Action::Set { id, value } => match store.set(&id, SecretValue::new(value)) {
Ok(()) => {
app.refresh(load_ids(&store)?);
app.notice = format!("Saved `{id}`");
}
Err(err) => {
app.notice = format!("Save failed for `{id}`: {err}");
}
},
Action::Delete { id } => match store.delete(&id) {
Ok(true) => {
app.refresh(load_ids(&store)?);
app.notice = format!("Deleted `{id}`");
}
Ok(false) => app.notice = format!("Secret `{id}` was already absent"),
Err(err) => app.notice = format!("Delete failed for `{id}`: {err}"),
},
}
}
}
}
fn load_ids(store: &SecretStore) -> UiResult<Vec<String>> {
Ok(store
.list_ids()?
.into_iter()
.map(|id| id.as_str().to_string())
.collect())
}
fn draw(frame: &mut ratatui::Frame<'_>, app: &KeysApp) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(5),
])
.split(area);
let title = Paragraph::new("Local secret keys (values are never displayed)").block(
Block::default()
.borders(Borders::ALL)
.title("insomnia keys"),
);
frame.render_widget(title, chunks[0]);
let items: Vec<ListItem<'_>> = if app.ids.is_empty() {
vec![ListItem::new(Line::from(Span::raw("No keys stored")))]
} else {
app.ids
.iter()
.map(|id| ListItem::new(Line::from(Span::raw(id.clone()))))
.collect()
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Key ids"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
if !app.ids.is_empty() {
state.select(Some(app.selected));
}
frame.render_stateful_widget(list, chunks[1], &mut state);
let input_line = match &app.mode {
Mode::Normal => "a add/set d delete ↑/↓ select q quit".to_string(),
Mode::EditingId => format!("Secret id: {}", app.input),
Mode::EditingValue { id } => format!(
"Value for `{id}`: {}",
"".repeat(app.input.chars().count())
),
Mode::ConfirmDelete { id } => format!("Delete `{id}`? y/N"),
};
let help = Paragraph::new(vec![
Line::from(input_line),
Line::from(app.notice.clone()),
Line::from("Protection is local obfuscation at rest, not a system keychain."),
])
.block(Block::default().borders(Borders::ALL).title("Actions"))
.wrap(Wrap { trim: true });
frame.render_widget(Clear, chunks[2]);
frame.render_widget(help, chunks[2]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_masks_value_and_emits_set_action() {
let mut app = KeysApp::new(vec![]);
assert_eq!(
app.handle_key(KeyCode::Char('a'), KeyModifiers::NONE),
Action::None
);
for c in "web/brave/test".chars() {
app.handle_key(KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(
app.handle_key(KeyCode::Enter, KeyModifiers::NONE),
Action::None
);
assert!(matches!(app.mode, Mode::EditingValue { .. }));
for c in "secret-value".chars() {
app.handle_key(KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(app.input, "secret-value");
let action = app.handle_key(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(
action,
Action::Set {
id: "web/brave/test".into(),
value: "secret-value".into()
}
);
assert!(!format!("{app:?}").contains("secret-value"));
}
#[test]
fn model_confirms_delete() {
let mut app = KeysApp::new(vec!["providers/anthropic/default".into()]);
assert_eq!(
app.handle_key(KeyCode::Char('d'), KeyModifiers::NONE),
Action::None
);
let action = app.handle_key(KeyCode::Char('y'), KeyModifiers::NONE);
assert_eq!(
action,
Action::Delete {
id: "providers/anthropic/default".into()
}
);
}
}

View File

@ -3,6 +3,7 @@ mod block;
mod cache;
mod command;
mod input;
pub mod keys;
mod markdown;
mod multi_pod;
mod picker;

View File

@ -147,6 +147,6 @@ Pod が操作できるファイルパスの制御。
| Task | `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | セッション内の短期 task 状態管理 |
| Memory / Knowledge | `MemoryQuery`, `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete`, `KnowledgeQuery` | manifest の memory 設定が有効な時に登録される durable memory / knowledge 操作 |
| Pod orchestration | `SpawnPod`, `SendToPod`, `ReadPodOutput`, `StopPod`, `ListPods`, `RestorePod` | child / visible Pod の起動・通信・停止・一覧・復元 |
| Web | `WebSearch`, `WebFetch` | manifest/env で明示設定された provider 経由の bounded web access |
| Web | `WebSearch`, `WebFetch` | manifest で明示設定された provider と local secret-store reference 経由の bounded web access |
すべての tool call は manifest tool permission と scope/policy のチェックを通る。ファイル write scope、Pod delegation、memory layout、web provider 設定はそれぞれ別の authority を持ち、UI 表示だけで権限を広げない。

View File

@ -40,21 +40,32 @@ Builtin profiles and catalogs are embedded in the binary at build time. User/pro
## Credential と外部 auth
Provider credential は、現在は manifest / profile / catalog の設定から env var 名を明示的に参照できる。ただし、これは移行互換のための現状であり、長期的な supported configuration path ではない。`manifest-profile-encrypted-secrets` で encrypted secret store と typed secret reference を導入し、credential env var 依存は削除する方針である。
Provider API key と WebSearch credential は、通常の runtime では環境変数から読まない。`insomnia keys` で local secret store に論理 id を追加し、profile / manifest / provider catalog / web config がその id を明示的に参照する。
これは「ambient な provider 自動発見」ではなく、設定で選んだ環境変数名を読む仕組みである。通常 runtime が `.env` を暗黙に load することもない。
```toml
[[models]]
id = "anthropic/claude-sonnet-4"
scheme = "anthropic"
model = "claude-sonnet-4-20250514"
auth = { kind = "secret_ref", ref = "providers/anthropic/default" }
[web]
enabled = true
[web.search]
provider = "brave"
api_key_secret = "web/brave/default"
```
Store の user-visible schema は `id -> value` だけであり、store は provider 名や kind を解釈しない。自動的な provider-name-to-secret-id lookup は行わず、設定が secret id を選ぶ。
On-disk store は `<data_dir>/secrets/store.json`。secret value は軽量な obfuscation と integrity check で plaintext config / log / terminal output に出にくくするが、OS keychain や passphrase vault ではない。data directory と source code を読める local user に対する強い保護は主張しない。
| 変数 / pattern | 用途 | 備考 |
| --- | --- | --- |
| `INSOMNIA_API_KEY_ANTHROPIC` | custom env が指定されていない場合の Anthropic API key の default env 名。 | provider auth resolution で使う。 |
| `INSOMNIA_API_KEY_OPENAI` | OpenAI / OpenAI Responses API key の default env 名。 | provider auth resolution で使う。 |
| `INSOMNIA_API_KEY_GEMINI` | Gemini API key の default env 名。 | provider auth resolution で使う。 |
| `INSOMNIA_API_KEY_OPENROUTER` | builtin OpenRouter provider の auth hint。 | bundled provider catalog 由来。 |
| custom `model.auth.env` value | manifest / profile ごとの API key env 名。 | 明示的な config が変数名を選ぶ。`auth.env` と `auth.file` が両方ある場合は env が優先される。 |
| `BRAVE_SEARCH_API_KEY` または custom `web.search.api_key_env` | Brave WebSearch key。 | WebSearch は configured env 名だけを読み、missing / empty の場合は fail closed する。 |
| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。 |
| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。Codex OAuth は local secret store とは別の structured integration のまま維持する。 |
Credential env var は interoperability のために現時点では残っているが、長期的に望ましい secret mechanism ではない。現時点では適切なら `auth.file` を優先し、今後は typed secret reference へ寄せる。credential UX のために implicit `.env` loading を追加しないこと。project secret を漏らしやすく、profile ごとの credential model とも相性が悪い。
通常 runtime が `.env` を暗黙に load することはない。credential UX のために implicit `.env` loading を追加しないこと。
## Development-only escape hatches
@ -83,7 +94,7 @@ Credential env var は interoperability のために現時点では残ってい
1. fallback / precedence の test は、process environment を読ませず、直接入力を渡せる小さな pure helper で検証する。
2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。
3. test が process environment を変更するのは、process env から読む thin wrapper 自体を検証する場合や、subprocess isolation に必要な場合に限る。
4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference へ寄せる。encrypted secret store 導入時に credential env var 依存を削除する
4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference を使う
5. fallback env は独立した設定項目として増やさず、対応する main key の解決順として文書化する。
6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。
7. 外部 process integration が env inheritance / filtering を必要とする場合は、ambient な inherited process state に頼らず、明示的な policy boundary として設計する。

View File

@ -2,7 +2,7 @@
Profiles are reusable Lua-authored recipes for generating an Insomnia runtime manifest. The Rust resolver evaluates a selected `.lua` profile in-process, validates that it is Profile-shaped rather than a complete Manifest, then binds runtime values such as Pod name and concrete scope to produce the persisted `PodManifest` snapshot.
Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch.
Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `insomnia keys` to store provider/WebSearch credentials, then reference explicit secret ids such as `auth = { kind = "secret_ref", ref = "providers/anthropic/default" }` or `web.search.api_key_secret = "web/brave/default"`. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch.
## Minimal profile

View File

@ -63,18 +63,16 @@ ref = "anthropic/claude-sonnet-4-6"
# base_url = "https://api.anthropic.com"
# 任意 (ref 未指定時は実質必須)。デフォルト: なし。
# kind の値: "none" | "api_key" | "codex_oauth"
# kind の値: "none" | "secret_ref" | "api_key" | "codex_oauth"
# - "none" … 認証不要 (ローカル Ollama 等)
# - "api_key" … env / file のいずれかで key を渡す。両方指定なら env 優先。
# env 未指定時は scheme ごとの既定環境変数:
# Anthropic -> INSOMNIA_API_KEY_ANTHROPIC
# OpenaiChat / OpenaiResponses -> INSOMNIA_API_KEY_OPENAI
# Gemini -> INSOMNIA_API_KEY_GEMINI
# file 指定時、相対パスは manifest base 起点で解決。
# - "secret_ref" … `insomnia keys` の local secret store から key を読む。
# `ref` はユーザー設定が明示的に選ぶ論理 secret id。
# store は id -> value のみを持ち、provider 種別を解釈しない。
# - "api_key" … 明示ファイルから key を読む低レベル形式。通常は
# `secret_ref` を使う。file 指定時、相対パスは manifest base 起点で解決。
# - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。
# auth = { kind = "none" }
# auth = { kind = "api_key" } # env のみ既定使用
# auth = { kind = "api_key", env = "MY_ANTHROPIC_KEY" }
# auth = { kind = "secret_ref", ref = "providers/anthropic/default" }
# auth = { kind = "api_key", file = "./sk-ant.local" }
# auth = { kind = "codex_oauth" }

View File

@ -36,7 +36,7 @@ return profile {
enabled = true,
search = {
provider = "brave",
api_key_env = "BRAVE_SEARCH_API_KEY",
api_key_secret = "web/brave/default",
},
},
}

View File

@ -3,7 +3,7 @@ id = "anthropic"
display_name = "Anthropic"
scheme = "anthropic"
base_url = "https://api.anthropic.com"
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_ANTHROPIC" }
auth_hint = { kind = "secret_ref", ref = "providers/anthropic/default" }
default_capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "budget_tokens", vision = true, prompt_caching = { kind = "explicit", max_breakpoints = 4 } }
default_context_window = 200000
@ -29,6 +29,6 @@ id = "openrouter"
display_name = "OpenRouter"
scheme = "openai_chat"
base_url = "https://openrouter.ai/api/v1"
auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_OPENROUTER" }
auth_hint = { kind = "secret_ref", ref = "providers/openrouter/default" }
default_capability = { tool_calling = "parallel", structured_output = "json_schema", vision = true, prompt_caching = { kind = "auto" } }
default_context_window = 200000