merge: local secret store
This commit is contained in:
commit
629159a29f
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()` の順で解決
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
14
crates/secrets/Cargo.toml
Normal 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
493
crates/secrets/src/lib.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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\"}]}}";
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
434
crates/tui/src/keys.rs
Normal file
434
crates/tui/src/keys.rs
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
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>>;
|
||||
|
||||
struct TerminalRestoreGuard {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl TerminalRestoreGuard {
|
||||
fn new() -> Self {
|
||||
Self { active: true }
|
||||
}
|
||||
|
||||
fn restore(mut self) {
|
||||
self.cleanup();
|
||||
self.active = false;
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) {
|
||||
let _ = execute!(io::stdout(), crossterm::cursor::Show, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalRestoreGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(store: SecretStore) -> UiResult<()> {
|
||||
enable_raw_mode()?;
|
||||
let guard = TerminalRestoreGuard::new();
|
||||
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);
|
||||
drop(terminal);
|
||||
guard.restore();
|
||||
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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ mod block;
|
|||
mod cache;
|
||||
mod command;
|
||||
mod input;
|
||||
pub mod keys;
|
||||
mod markdown;
|
||||
mod multi_pod;
|
||||
mod picker;
|
||||
|
|
|
|||
|
|
@ -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 表示だけで権限を広げない。
|
||||
|
|
|
|||
|
|
@ -40,21 +40,30 @@ 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
|
||||
[model]
|
||||
ref = "anthropic/claude-sonnet-4-6"
|
||||
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 +92,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 として設計する。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ return profile {
|
|||
enabled = true,
|
||||
search = {
|
||||
provider = "brave",
|
||||
api_key_env = "BRAVE_SEARCH_API_KEY",
|
||||
api_key_secret = "web/brave/default",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user