secrets: add local key store
This commit is contained in:
parent
6e5ed683d6
commit
cc2c9a2973
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -2473,6 +2473,7 @@ dependencies = [
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
"manifest",
|
"manifest",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"secrets",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
|
|
@ -3047,6 +3048,17 @@ version = "3.0.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secrets"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|
@ -3783,6 +3795,7 @@ dependencies = [
|
||||||
"markup5ever_rcdom",
|
"markup5ever_rcdom",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"schemars",
|
"schemars",
|
||||||
|
"secrets",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
|
@ -3932,6 +3945,7 @@ dependencies = [
|
||||||
"protocol",
|
"protocol",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"secrets",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ members = [
|
||||||
"crates/llm-worker",
|
"crates/llm-worker",
|
||||||
"crates/llm-worker-macros",
|
"crates/llm-worker-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/pod",
|
"crates/pod",
|
||||||
"crates/insomnia",
|
"crates/insomnia",
|
||||||
|
|
@ -41,6 +42,7 @@ protocol = { path = "crates/protocol" }
|
||||||
provider = { path = "crates/provider" }
|
provider = { path = "crates/provider" }
|
||||||
session-metrics = { path = "crates/session-metrics" }
|
session-metrics = { path = "crates/session-metrics" }
|
||||||
session-store = { path = "crates/session-store" }
|
session-store = { path = "crates/session-store" }
|
||||||
|
secrets = { path = "crates/secrets" }
|
||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
tui = { path = "crates/tui" }
|
tui = { path = "crates/tui" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ enum Mode {
|
||||||
MemoryLintHelp,
|
MemoryLintHelp,
|
||||||
MemoryLint(LintCliOptions),
|
MemoryLint(LintCliOptions),
|
||||||
PodRuntime(Vec<String>),
|
PodRuntime(Vec<String>),
|
||||||
|
Keys,
|
||||||
Tui(LaunchMode),
|
Tui(LaunchMode),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +59,7 @@ async fn main() -> ExitCode {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("insomnia pod", args).await,
|
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("insomnia pod", args).await,
|
||||||
|
Mode::Keys => tui::keys::launch().await,
|
||||||
Mode::Tui(mode) => {
|
Mode::Tui(mode) => {
|
||||||
let runtime_command = match PodRuntimeCommand::resolve() {
|
let runtime_command = match PodRuntimeCommand::resolve() {
|
||||||
Ok(command) => command,
|
Ok(command) => command,
|
||||||
|
|
@ -96,6 +98,12 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
match args[0].as_str() {
|
match args[0].as_str() {
|
||||||
"--help" | "-h" => return Ok(Mode::Help),
|
"--help" | "-h" => return Ok(Mode::Help),
|
||||||
"pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())),
|
"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") => {
|
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
|
||||||
let lint_args = &args[2..];
|
let lint_args = &args[2..];
|
||||||
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
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() {
|
fn print_help() {
|
||||||
println!(
|
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]
|
#[test]
|
||||||
fn parse_literal_pod_name_still_available_with_flag() {
|
fn parse_literal_pod_name_still_available_with_flag() {
|
||||||
match parse_args_from(["--pod", "pod"]).unwrap() {
|
match parse_args_from(["--pod", "pod"]).unwrap() {
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,7 @@ impl crate::WebSearchConfig {
|
||||||
Self {
|
Self {
|
||||||
enabled: upper.enabled.or(self.enabled),
|
enabled: upper.enabled.or(self.enabled),
|
||||||
provider: upper.provider.or(self.provider),
|
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),
|
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
|
||||||
base_url: upper.base_url.or(self.base_url),
|
base_url: upper.base_url.or(self.base_url),
|
||||||
country: upper.country.or(self.country),
|
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) {
|
fn resolve_auth_file(auth: &mut Option<AuthRef>, base: &Path) {
|
||||||
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
|
if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
|
||||||
*p = join_if_relative(base, p);
|
*p = join_if_relative(base, p);
|
||||||
|
|
@ -692,10 +692,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_key_file_auth(path: PathBuf) -> AuthRef {
|
fn api_key_file_auth(path: PathBuf) -> AuthRef {
|
||||||
AuthRef::ApiKey {
|
AuthRef::ApiKey { file: Some(path) }
|
||||||
env: None,
|
|
||||||
file: Some(path),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_valid() -> PodManifestConfig {
|
fn minimal_valid() -> PodManifestConfig {
|
||||||
|
|
@ -1089,7 +1086,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
web: Some(WebConfig {
|
web: Some(WebConfig {
|
||||||
search: Some(crate::WebSearchConfig {
|
search: Some(crate::WebSearchConfig {
|
||||||
api_key_env: Some("LOWER_BRAVE_KEY".into()),
|
api_key_secret: Some("web/brave/lower".into()),
|
||||||
timeout_secs: Some(12),
|
timeout_secs: Some(12),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
|
@ -1118,7 +1115,7 @@ mod tests {
|
||||||
assert_eq!(c.prune_protected_tokens, Some(5_000));
|
assert_eq!(c.prune_protected_tokens, Some(5_000));
|
||||||
let search = merged.web.unwrap().search.unwrap();
|
let search = merged.web.unwrap().search.unwrap();
|
||||||
assert_eq!(search.timeout_secs, Some(3));
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -122,10 +122,10 @@ pub struct WebSearchConfig {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub provider: Option<WebSearchProvider>,
|
pub provider: Option<WebSearchProvider>,
|
||||||
/// Environment variable that stores the provider API key. Raw secrets do
|
/// Local secret-store id for the provider API key. Raw secrets do not
|
||||||
/// not belong in manifest files.
|
/// belong in manifest files.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub api_key_env: Option<String>,
|
pub api_key_secret: Option<String>,
|
||||||
/// Request timeout in seconds. Tool implementation applies a safe default
|
/// Request timeout in seconds. Tool implementation applies a safe default
|
||||||
/// when this is omitted.
|
/// when this is omitted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -651,7 +651,7 @@ permission = "write"
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_web_config() {
|
fn parse_web_config() {
|
||||||
let toml = format!(
|
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
|
MINIMAL_REQUIRED
|
||||||
);
|
);
|
||||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
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` で行う。ここはあくまで「どこから取るか」の宣言。
|
/// `crates/provider` で行う。ここはあくまで「どこから取るか」の宣言。
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
|
@ -105,11 +105,10 @@ pub enum AuthRef {
|
||||||
/// 認証不要(ローカル Ollama 等)
|
/// 認証不要(ローカル Ollama 等)
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
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 {
|
ApiKey {
|
||||||
/// 環境変数名。未指定のときは scheme ごとの既定(`INSOMNIA_API_KEY_*`)
|
|
||||||
#[serde(default)]
|
|
||||||
env: Option<String>,
|
|
||||||
/// key を書き込んだファイル(絶対パス)
|
/// key を書き込んだファイル(絶対パス)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
file: Option<PathBuf>,
|
file: Option<PathBuf>,
|
||||||
|
|
@ -117,8 +116,8 @@ pub enum AuthRef {
|
||||||
/// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
|
/// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット
|
||||||
#[serde(rename = "codex_oauth")]
|
#[serde(rename = "codex_oauth")]
|
||||||
CodexOAuth,
|
CodexOAuth,
|
||||||
/// Typed secret-store reference. The profile resolver preserves this
|
/// Typed local secret-store reference. The profile resolver preserves this
|
||||||
/// reference verbatim; secret-store lookup/decryption is intentionally a
|
/// reference verbatim; secret-store lookup/deobfuscation is intentionally a
|
||||||
/// later consumer-boundary concern.
|
/// later consumer-boundary concern.
|
||||||
#[serde(rename = "secret_ref")]
|
#[serde(rename = "secret_ref")]
|
||||||
SecretRef {
|
SecretRef {
|
||||||
|
|
@ -126,16 +125,3 @@ pub enum AuthRef {
|
||||||
ref_: String,
|
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>,
|
auth: &Option<AuthRef>,
|
||||||
field: &'static str,
|
field: &'static str,
|
||||||
) -> Result<(), ProfileError> {
|
) -> Result<(), ProfileError> {
|
||||||
if let Some(AuthRef::ApiKey {
|
if let Some(AuthRef::ApiKey { file: Some(file) }) = auth
|
||||||
file: Some(file), ..
|
|
||||||
}) = auth
|
|
||||||
&& file.is_absolute()
|
&& file.is_absolute()
|
||||||
{
|
{
|
||||||
return Err(ProfileError::InvalidProfile(format!(
|
return Err(ProfileError::InvalidProfile(format!(
|
||||||
|
|
|
||||||
|
|
@ -991,7 +991,6 @@ return profile {
|
||||||
base_url: Some("https://example.test".into()),
|
base_url: Some("https://example.test".into()),
|
||||||
model_id: Some("claude-sonnet-4".into()),
|
model_id: Some("claude-sonnet-4".into()),
|
||||||
auth: Some(AuthRef::ApiKey {
|
auth: Some(AuthRef::ApiKey {
|
||||||
env: None,
|
|
||||||
file: Some(PathBuf::from("/etc/keys/anthropic")),
|
file: Some(PathBuf::from("/etc/keys/anthropic")),
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ base64 = "0.22.1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
|
||||||
llm-worker = { workspace = true }
|
llm-worker = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
|
secrets = { workspace = true }
|
||||||
reqwest = { version = "0.13", features = ["json", "native-tls"] }
|
reqwest = { version = "0.13", features = ["json", "native-tls"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# provider
|
# 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`) の解決
|
- プロバイダ / モデルカタログの builtin (`resources/{providers,models}/builtin.toml`) と user override (`$XDG_CONFIG_HOME/insomnia/{providers,models}.toml`) の解決
|
||||||
- `ModelManifest` の ref 形を `(provider, model_id)` に split し、`ModelConfig` へ展開
|
- `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` の解決
|
- `AuthRef::None` / `AuthRef::CodexOAuth` の解決
|
||||||
- `Scheme::required_auth()` と `ResolvedAuth` の妥当性検証(非対応組合せは構築エラー)
|
- `Scheme::required_auth()` と `ResolvedAuth` の妥当性検証(非対応組合せは構築エラー)
|
||||||
- capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決
|
- capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,15 @@ pub enum ResolveError {
|
||||||
pub enum AuthHint {
|
pub enum AuthHint {
|
||||||
/// 認証不要(ローカル Ollama 等)
|
/// 認証不要(ローカル Ollama 等)
|
||||||
None,
|
None,
|
||||||
/// API key。`env` が指定されていれば UI はその env 名を提示する
|
/// API key file reference. Normal credential configuration should prefer
|
||||||
ApiKey {
|
/// [`AuthHint::SecretRef`] so plaintext credentials stay out of manifests.
|
||||||
#[serde(default)]
|
ApiKey,
|
||||||
env: Option<String>,
|
/// 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`)
|
/// ChatGPT OAuth(`~/.codex/auth.json`)
|
||||||
#[serde(rename = "codex_oauth")]
|
#[serde(rename = "codex_oauth")]
|
||||||
|
|
@ -153,16 +158,12 @@ struct ModelCatalogFile {
|
||||||
model: Vec<ModelEntry>,
|
model: Vec<ModelEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。env / file は
|
/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。
|
||||||
/// マニフェスト側で override 可能なので、ここでは hint そのままを
|
|
||||||
/// 反映した最小形だけを返す(`AuthRef::ApiKey { env: hint_env, file: None }`)。
|
|
||||||
fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef {
|
fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef {
|
||||||
match hint {
|
match hint {
|
||||||
AuthHint::None => AuthRef::None,
|
AuthHint::None => AuthRef::None,
|
||||||
AuthHint::ApiKey { env } => AuthRef::ApiKey {
|
AuthHint::ApiKey => AuthRef::ApiKey { file: None },
|
||||||
env: env.clone(),
|
AuthHint::SecretRef { ref_ } => AuthRef::SecretRef { ref_: ref_.clone() },
|
||||||
file: None,
|
|
||||||
},
|
|
||||||
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
|
AuthHint::CodexOAuth => AuthRef::CodexOAuth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -415,11 +416,10 @@ mod tests {
|
||||||
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
|
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
|
||||||
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com"));
|
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com"));
|
||||||
match cfg.auth {
|
match cfg.auth {
|
||||||
AuthRef::ApiKey { env, file } => {
|
AuthRef::SecretRef { ref_ } => {
|
||||||
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC"));
|
assert_eq!(ref_, "providers/anthropic/default");
|
||||||
assert!(file.is_none());
|
|
||||||
}
|
}
|
||||||
_ => panic!("expected ApiKey auth from provider hint"),
|
_ => panic!("expected SecretRef auth from provider hint"),
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
cfg.capability.is_some(),
|
cfg.capability.is_some(),
|
||||||
|
|
@ -493,15 +493,13 @@ mod tests {
|
||||||
let manifest = ModelManifest {
|
let manifest = ModelManifest {
|
||||||
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
||||||
auth: Some(AuthRef::ApiKey {
|
auth: Some(AuthRef::ApiKey {
|
||||||
env: None,
|
|
||||||
file: Some(PathBuf::from("/tmp/sk-ant")),
|
file: Some(PathBuf::from("/tmp/sk-ant")),
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
||||||
match cfg.auth {
|
match cfg.auth {
|
||||||
AuthRef::ApiKey { env, file } => {
|
AuthRef::ApiKey { file } => {
|
||||||
assert!(env.is_none());
|
|
||||||
assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant")));
|
assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant")));
|
||||||
}
|
}
|
||||||
_ => panic!("override auth should win"),
|
_ => panic!("override auth should win"),
|
||||||
|
|
@ -555,7 +553,6 @@ mod tests {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
model_id: Some("claude-sonnet-4-6".into()),
|
model_id: Some("claude-sonnet-4-6".into()),
|
||||||
auth: Some(AuthRef::ApiKey {
|
auth: Some(AuthRef::ApiKey {
|
||||||
env: None,
|
|
||||||
file: Some(PathBuf::from("/tmp/sk")),
|
file: Some(PathBuf::from("/tmp/sk")),
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -575,7 +572,6 @@ mod tests {
|
||||||
scheme: Some(SchemeKind::Anthropic),
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
model_id: Some("claude-sonnet-4-6".into()),
|
model_id: Some("claude-sonnet-4-6".into()),
|
||||||
auth: Some(AuthRef::ApiKey {
|
auth: Some(AuthRef::ApiKey {
|
||||||
env: None,
|
|
||||||
file: Some(PathBuf::from("/tmp/sk")),
|
file: Some(PathBuf::from("/tmp/sk")),
|
||||||
}),
|
}),
|
||||||
context_window: Some(777_000),
|
context_window: Some(777_000),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//! 段階:
|
//! 段階:
|
||||||
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
|
//! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で
|
||||||
//! カタログ込み [`ModelConfig`] に解決(ref → 展開 / inline → 検証)
|
//! カタログ込み [`ModelConfig`] に解決(ref → 展開 / inline → 検証)
|
||||||
//! 2. `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に
|
//! 2. `AuthRef` を local secret store / ファイルから解決して [`ResolvedAuth`] に
|
||||||
//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー)
|
//! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー)
|
||||||
//! 4. `ModelCapability` は manifest 明示 > model catalog > provider
|
//! 4. `ModelCapability` は manifest 明示 > model catalog > provider
|
||||||
//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は
|
//! default_capability > scheme 既定 の順でフォールバック(上位 3 段は
|
||||||
|
|
@ -30,6 +30,7 @@ use llm_worker::llm_client::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use manifest::{AuthRef, ModelManifest, SchemeKind};
|
use manifest::{AuthRef, ModelManifest, SchemeKind};
|
||||||
|
use secrets::{SecretStore, SecretValue};
|
||||||
|
|
||||||
pub use catalog::{ModelConfig, ResolveError as CatalogResolveError};
|
pub use catalog::{ModelConfig, ResolveError as CatalogResolveError};
|
||||||
|
|
||||||
|
|
@ -42,6 +43,13 @@ pub enum ProviderError {
|
||||||
#[error("API key not provided for scheme {scheme:?}")]
|
#[error("API key not provided for scheme {scheme:?}")]
|
||||||
ApiKeyMissing { scheme: SchemeKind },
|
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")]
|
#[error("scheme {scheme:?} does not support this auth")]
|
||||||
AuthMismatch { scheme: SchemeKind },
|
AuthMismatch { scheme: SchemeKind },
|
||||||
|
|
||||||
|
|
@ -53,21 +61,38 @@ pub enum ProviderError {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。
|
/// `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> {
|
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 {
|
match auth {
|
||||||
AuthRef::None => Ok(ResolvedAuth::None),
|
AuthRef::None => Ok(ResolvedAuth::None),
|
||||||
AuthRef::ApiKey { env, file } => {
|
AuthRef::ApiKey { 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));
|
|
||||||
}
|
|
||||||
if let Some(path) = file {
|
if let Some(path) = file {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err(ProviderError::Config(format!(
|
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()))?;
|
.map_err(|e| ProviderError::Config(e.to_string()))?;
|
||||||
Ok(ResolvedAuth::Custom(Arc::new(provider)))
|
Ok(ResolvedAuth::Custom(Arc::new(provider)))
|
||||||
}
|
}
|
||||||
AuthRef::SecretRef { ref_ } => Err(ProviderError::Config(format!(
|
AuthRef::SecretRef { ref_ } => {
|
||||||
"secret store references are not implemented yet: {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::io::Write;
|
||||||
use std::path::PathBuf;
|
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 {
|
fn anthropic_config() -> ModelConfig {
|
||||||
ModelConfig {
|
ModelConfig {
|
||||||
scheme: SchemeKind::Anthropic,
|
scheme: SchemeKind::Anthropic,
|
||||||
base_url: None,
|
base_url: None,
|
||||||
model_id: "claude-sonnet-4-20250514".into(),
|
model_id: "claude-sonnet-4-20250514".into(),
|
||||||
auth: AuthRef::ApiKey {
|
auth: AuthRef::ApiKey { file: None },
|
||||||
env: None,
|
|
||||||
file: None,
|
|
||||||
},
|
|
||||||
capability: None,
|
capability: None,
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
max_context_window: None,
|
max_context_window: None,
|
||||||
|
|
@ -195,18 +235,41 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
fn resolve_from_secret_ref() {
|
||||||
fn resolve_from_env() {
|
let resolver = TestSecrets(std::collections::BTreeMap::from([(
|
||||||
let env_name = SchemeKind::Anthropic.default_env_var();
|
"providers/anthropic/default".to_string(),
|
||||||
unsafe { std::env::set_var(env_name, "sk-from-env") };
|
"sk-from-store".to_string(),
|
||||||
let auth = resolve_auth(SchemeKind::Anthropic, &anthropic_config().auth).unwrap();
|
)]));
|
||||||
unsafe { std::env::remove_var(env_name) };
|
let auth = resolve_auth_with_resolver(
|
||||||
|
SchemeKind::Anthropic,
|
||||||
|
&AuthRef::SecretRef {
|
||||||
|
ref_: "providers/anthropic/default".into(),
|
||||||
|
},
|
||||||
|
&resolver,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
match auth {
|
match auth {
|
||||||
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"),
|
ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-store"),
|
||||||
_ => panic!("expected ApiKey"),
|
_ => 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]
|
#[test]
|
||||||
fn resolve_from_file() {
|
fn resolve_from_file() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -217,7 +280,6 @@ mod tests {
|
||||||
}
|
}
|
||||||
let config = ModelConfig {
|
let config = ModelConfig {
|
||||||
auth: AuthRef::ApiKey {
|
auth: AuthRef::ApiKey {
|
||||||
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
|
|
||||||
file: Some(key_path),
|
file: Some(key_path),
|
||||||
},
|
},
|
||||||
..anthropic_config()
|
..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]
|
#[test]
|
||||||
fn relative_auth_file_is_rejected() {
|
fn relative_auth_file_is_rejected() {
|
||||||
let config = ModelConfig {
|
let config = ModelConfig {
|
||||||
auth: AuthRef::ApiKey {
|
auth: AuthRef::ApiKey {
|
||||||
env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()),
|
|
||||||
file: Some(PathBuf::from("keys/anthropic")),
|
file: Some(PathBuf::from("keys/anthropic")),
|
||||||
},
|
},
|
||||||
..anthropic_config()
|
..anthropic_config()
|
||||||
|
|
@ -270,8 +306,6 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn missing_key_returns_api_key_missing() {
|
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());
|
let result = build_client_from_config(&anthropic_config());
|
||||||
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
|
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"
|
html5ever = "0.26"
|
||||||
llm-worker = { workspace = true }
|
llm-worker = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
|
secrets = { workspace = true }
|
||||||
markup5ever_rcdom = "0.2"
|
markup5ever_rcdom = "0.2"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
||||||
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, LOCATION};
|
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, LOCATION};
|
||||||
use reqwest::{Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
|
use secrets::SecretStore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::net::lookup_host;
|
use tokio::net::lookup_host;
|
||||||
|
|
@ -36,6 +37,7 @@ const WEB_FETCH_TRUNCATION_MARKER: &str = "\n[truncated]";
|
||||||
pub struct WebTools {
|
pub struct WebTools {
|
||||||
config: Option<WebConfig>,
|
config: Option<WebConfig>,
|
||||||
client: Client,
|
client: Client,
|
||||||
|
secret_store: Option<SecretStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebTools {
|
impl WebTools {
|
||||||
|
|
@ -45,7 +47,25 @@ impl WebTools {
|
||||||
.user_agent("insomnia-web-tools/0.1")
|
.user_agent("insomnia-web-tools/0.1")
|
||||||
.build()
|
.build()
|
||||||
.expect("static reqwest client configuration is valid");
|
.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 {
|
fn global_enabled(&self) -> bool {
|
||||||
|
|
@ -153,11 +173,19 @@ impl WebTools {
|
||||||
match cfg.provider.ok_or_else(|| {
|
match cfg.provider.ok_or_else(|| {
|
||||||
disabled_error(
|
disabled_error(
|
||||||
"WebSearch",
|
"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 => {
|
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(
|
async fn brave_search(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
cfg: &WebSearchConfig,
|
cfg: &WebSearchConfig,
|
||||||
|
secret_store: Option<&SecretStore>,
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> 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(
|
disabled_error(
|
||||||
"WebSearch",
|
"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!(
|
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!(
|
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(
|
async fn brave_search_with_api_key(
|
||||||
|
|
@ -1709,7 +1744,7 @@ mod tests {
|
||||||
WebSearchConfig {
|
WebSearchConfig {
|
||||||
enabled: Some(true),
|
enabled: Some(true),
|
||||||
provider: Some(WebSearchProvider::Brave),
|
provider: Some(WebSearchProvider::Brave),
|
||||||
api_key_env: None,
|
api_key_secret: Some("web/brave/test".into()),
|
||||||
timeout_secs: Some(2),
|
timeout_secs: Some(2),
|
||||||
base_url: Some(base_url),
|
base_url: Some(base_url),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -2037,6 +2072,49 @@ mod tests {
|
||||||
assert_eq!(value.get("redirects").unwrap().as_array().unwrap().len(), 1);
|
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]
|
#[tokio::test]
|
||||||
async fn searches_brave_with_bounded_output() {
|
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\"}]}}";
|
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 }
|
uuid = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
|
secrets = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
pod-store = { workspace = true }
|
pod-store = { workspace = true }
|
||||||
pod-registry = { workspace = true }
|
pod-registry = { workspace = true }
|
||||||
|
|
|
||||||
406
crates/tui/src/keys.rs
Normal file
406
crates/tui/src/keys.rs
Normal 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()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
mod input;
|
mod input;
|
||||||
|
pub mod keys;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod multi_pod;
|
mod multi_pod;
|
||||||
mod picker;
|
mod picker;
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,6 @@ Pod が操作できるファイルパスの制御。
|
||||||
| Task | `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | セッション内の短期 task 状態管理 |
|
| Task | `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | セッション内の短期 task 状態管理 |
|
||||||
| Memory / Knowledge | `MemoryQuery`, `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete`, `KnowledgeQuery` | manifest の memory 設定が有効な時に登録される durable memory / knowledge 操作 |
|
| Memory / Knowledge | `MemoryQuery`, `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete`, `KnowledgeQuery` | manifest の memory 設定が有効な時に登録される durable memory / knowledge 操作 |
|
||||||
| Pod orchestration | `SpawnPod`, `SendToPod`, `ReadPodOutput`, `StopPod`, `ListPods`, `RestorePod` | child / visible Pod の起動・通信・停止・一覧・復元 |
|
| 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 表示だけで権限を広げない。
|
すべての tool call は manifest tool permission と scope/policy のチェックを通る。ファイル write scope、Pod delegation、memory layout、web provider 設定はそれぞれ別の authority を持ち、UI 表示だけで権限を広げない。
|
||||||
|
|
|
||||||
|
|
@ -40,21 +40,32 @@ Builtin profiles and catalogs are embedded in the binary at build time. User/pro
|
||||||
|
|
||||||
## Credential と外部 auth
|
## 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 | 用途 | 備考 |
|
| 変数 / pattern | 用途 | 備考 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `INSOMNIA_API_KEY_ANTHROPIC` | custom env が指定されていない場合の Anthropic API key の default env 名。 | provider auth resolution で使う。 |
|
| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。Codex OAuth は local secret store とは別の structured integration のまま維持する。 |
|
||||||
| `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`。 |
|
|
||||||
|
|
||||||
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
|
## Development-only escape hatches
|
||||||
|
|
||||||
|
|
@ -83,7 +94,7 @@ Credential env var は interoperability のために現時点では残ってい
|
||||||
1. fallback / precedence の test は、process environment を読ませず、直接入力を渡せる小さな pure helper で検証する。
|
1. fallback / precedence の test は、process environment を読ませず、直接入力を渡せる小さな pure helper で検証する。
|
||||||
2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。
|
2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。
|
||||||
3. test が process environment を変更するのは、process env から読む thin wrapper 自体を検証する場合や、subprocess isolation に必要な場合に限る。
|
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 の解決順として文書化する。
|
5. fallback env は独立した設定項目として増やさず、対応する main key の解決順として文書化する。
|
||||||
6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。
|
6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。
|
||||||
7. 外部 process integration が env inheritance / filtering を必要とする場合は、ambient な inherited process state に頼らず、明示的な policy boundary として設計する。
|
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 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
|
## Minimal profile
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,18 +63,16 @@ ref = "anthropic/claude-sonnet-4-6"
|
||||||
# base_url = "https://api.anthropic.com"
|
# base_url = "https://api.anthropic.com"
|
||||||
|
|
||||||
# 任意 (ref 未指定時は実質必須)。デフォルト: なし。
|
# 任意 (ref 未指定時は実質必須)。デフォルト: なし。
|
||||||
# kind の値: "none" | "api_key" | "codex_oauth"
|
# kind の値: "none" | "secret_ref" | "api_key" | "codex_oauth"
|
||||||
# - "none" … 認証不要 (ローカル Ollama 等)
|
# - "none" … 認証不要 (ローカル Ollama 等)
|
||||||
# - "api_key" … env / file のいずれかで key を渡す。両方指定なら env 優先。
|
# - "secret_ref" … `insomnia keys` の local secret store から key を読む。
|
||||||
# env 未指定時は scheme ごとの既定環境変数:
|
# `ref` はユーザー設定が明示的に選ぶ論理 secret id。
|
||||||
# Anthropic -> INSOMNIA_API_KEY_ANTHROPIC
|
# store は id -> value のみを持ち、provider 種別を解釈しない。
|
||||||
# OpenaiChat / OpenaiResponses -> INSOMNIA_API_KEY_OPENAI
|
# - "api_key" … 明示ファイルから key を読む低レベル形式。通常は
|
||||||
# Gemini -> INSOMNIA_API_KEY_GEMINI
|
# `secret_ref` を使う。file 指定時、相対パスは manifest base 起点で解決。
|
||||||
# file 指定時、相対パスは manifest base 起点で解決。
|
|
||||||
# - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。
|
# - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。
|
||||||
# auth = { kind = "none" }
|
# auth = { kind = "none" }
|
||||||
# auth = { kind = "api_key" } # env のみ既定使用
|
# auth = { kind = "secret_ref", ref = "providers/anthropic/default" }
|
||||||
# auth = { kind = "api_key", env = "MY_ANTHROPIC_KEY" }
|
|
||||||
# auth = { kind = "api_key", file = "./sk-ant.local" }
|
# auth = { kind = "api_key", file = "./sk-ant.local" }
|
||||||
# auth = { kind = "codex_oauth" }
|
# auth = { kind = "codex_oauth" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ return profile {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
search = {
|
search = {
|
||||||
provider = "brave",
|
provider = "brave",
|
||||||
api_key_env = "BRAVE_SEARCH_API_KEY",
|
api_key_secret = "web/brave/default",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ id = "anthropic"
|
||||||
display_name = "Anthropic"
|
display_name = "Anthropic"
|
||||||
scheme = "anthropic"
|
scheme = "anthropic"
|
||||||
base_url = "https://api.anthropic.com"
|
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_capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "budget_tokens", vision = true, prompt_caching = { kind = "explicit", max_breakpoints = 4 } }
|
||||||
default_context_window = 200000
|
default_context_window = 200000
|
||||||
|
|
||||||
|
|
@ -29,6 +29,6 @@ id = "openrouter"
|
||||||
display_name = "OpenRouter"
|
display_name = "OpenRouter"
|
||||||
scheme = "openai_chat"
|
scheme = "openai_chat"
|
||||||
base_url = "https://openrouter.ai/api/v1"
|
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_capability = { tool_calling = "parallel", structured_output = "json_schema", vision = true, prompt_caching = { kind = "auto" } }
|
||||||
default_context_window = 200000
|
default_context_window = 200000
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user