From af9cc118e78dbebf5a9d622f0488952a82fea8b7 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 5 Jun 2026 20:44:28 +0200 Subject: [PATCH] fix: inherit shell ai provider env --- docs/ABSTRACTIONS.md | 3 +- docs/ARCHITECTURE.md | 6 +- src-tauri/src/ai_models.rs | 33 ++- src-tauri/src/claude_cli.rs | 40 +++- src-tauri/src/cli_agent_runtime.rs | 5 + src-tauri/src/cli_agent_runtime/shell_env.rs | 238 +++++++++++++++++++ 6 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/cli_agent_runtime/shell_env.rs diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 27e28564..d18af896 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -853,6 +853,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc - Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key) - Only shows after vault onboarding has already resolved to a ready state - Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources +- App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets - The shared `useAiAgentsStatus` hook defers that command until after the first render and skips it when AI features are disabled or the current window cannot render AI status surfaces - Persists dismissal locally once the user continues @@ -909,7 +910,7 @@ interface Settings { } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. +Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a62e2fe..7e8d59a4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -280,7 +280,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. 5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault. -CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation. +CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path. Claude Code launches also copy a narrow set of exported provider/auth environment variables from the app process or the user's zsh/bash startup files, including Anthropic API/base URL values, so company proxy and API-key setups that work in Terminal also work when Tolaria is opened from Finder or Dock. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation. CLI-agent system prompts also include a local Tolaria docs orientation when the bundled docs resource is present. `scripts/build-agent-docs.mjs` generates `src-tauri/resources/agent-docs/` from the public VitePress Markdown sources, including `index.md`, `AGENTS.md`, per-section bundles, `all.md`, `search-index.json`, and generated per-page files. Tauri bundles that folder as `agent-docs/`; `get_agent_docs_path` resolves the installed resource path, with a repository fallback for development, and `getAgentDocsPath()` caches it before each agent run. Agents are instructed to read the active vault's `AGENTS.md` for local conventions and search the bundled docs for Tolaria product behavior. @@ -345,11 +345,11 @@ Large active notes are compacted into a head/tail body snapshot before they ente Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints. -Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Local endpoints can omit authentication. +Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication. ### Authentication -Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup. +Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login or user-managed Anthropic/provider environment variables; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup. ## MCP Server diff --git a/src-tauri/src/ai_models.rs b/src-tauri/src/ai_models.rs index 6c206eee..056148e9 100644 --- a/src-tauri/src/ai_models.rs +++ b/src-tauri/src/ai_models.rs @@ -423,18 +423,32 @@ fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result Result, String> { + api_key_from_env_with_lookup( + request, + crate::cli_agent_runtime::env_value_from_process_or_user_shell, + ) +} + +fn api_key_from_env_with_lookup( + request: &AiModelStreamRequest, + lookup: impl Fn(crate::cli_agent_runtime::EnvName<'_>) -> Option, +) -> Result, String> { let Some(name) = request .provider .api_key_env_var .as_deref() .and_then(non_empty_str) + .and_then(crate::cli_agent_runtime::EnvName::new) else { return Ok(None); }; - std::env::var(name) - .map(Some) - .map_err(|_| format!("Environment variable {name} is not set for this AI provider.")) + lookup(name).map(Some).ok_or_else(|| { + format!( + "Environment variable {} is not set for this AI provider.", + name.as_str() + ) + }) } fn api_key_from_provider(request: &AiModelStreamRequest) -> Result, String> { @@ -636,6 +650,19 @@ mod tests { ); } + #[test] + fn env_api_key_uses_shell_lookup_when_process_env_is_missing() { + let mut provider = provider(AiModelProviderKind::Anthropic); + provider.api_key_storage = Some(AiModelApiKeyStorage::Env); + provider.api_key_env_var = Some("ANTHROPIC_API_KEY".into()); + let request = request(provider); + + let api_key = + api_key_from_env_with_lookup(&request, |_| Some("shell-secret".to_string())).unwrap(); + + assert_eq!(api_key.as_deref(), Some("shell-secret")); + } + #[test] fn extracts_provider_text_payloads_and_reports_empty_responses() { let openai = json!({ diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 59a72bd2..8b9bff19 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -1,9 +1,36 @@ pub use crate::cli_agent_runtime::AgentStreamRequest; +use crate::cli_agent_runtime::EnvName; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Stdio}; +const CLAUDE_PROVIDER_ENV_KEYS: &[EnvName<'static>] = &[ + EnvName::trusted("ANTHROPIC_API_KEY"), + EnvName::trusted("ANTHROPIC_AUTH_TOKEN"), + EnvName::trusted("ANTHROPIC_BASE_URL"), + EnvName::trusted("ANTHROPIC_CUSTOM_HEADERS"), + EnvName::trusted("ANTHROPIC_MODEL"), + EnvName::trusted("ANTHROPIC_SMALL_FAST_MODEL"), + EnvName::trusted("CLAUDE_CODE_USE_BEDROCK"), + EnvName::trusted("CLAUDE_CODE_USE_VERTEX"), + EnvName::trusted("AWS_ACCESS_KEY_ID"), + EnvName::trusted("AWS_SECRET_ACCESS_KEY"), + EnvName::trusted("AWS_SESSION_TOKEN"), + EnvName::trusted("AWS_PROFILE"), + EnvName::trusted("AWS_REGION"), + EnvName::trusted("AWS_DEFAULT_REGION"), + EnvName::trusted("GOOGLE_APPLICATION_CREDENTIALS"), + EnvName::trusted("CLOUD_ML_REGION"), + EnvName::trusted("VERTEX_REGION"), + EnvName::trusted("HTTPS_PROXY"), + EnvName::trusted("HTTP_PROXY"), + EnvName::trusted("NO_PROXY"), + EnvName::trusted("SSL_CERT_FILE"), + EnvName::trusted("SSL_CERT_DIR"), + EnvName::trusted("NODE_EXTRA_CA_CERTS"), +]; + /// Status returned by `check_claude_cli`. #[derive(Debug, Serialize, Clone)] pub struct ClaudeCliStatus { @@ -393,7 +420,7 @@ fn build_claude_command( ) -> Result { let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?; let mut cmd = crate::hidden_command(&target.program); - crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, request.bin); + configure_claude_command_environment(&mut cmd, request.bin); if let Some(first_arg) = target.first_arg { cmd.arg(first_arg); } @@ -408,6 +435,11 @@ fn build_claude_command( Ok(cmd) } +fn configure_claude_command_environment(cmd: &mut std::process::Command, bin: &Path) { + crate::cli_agent_runtime::configure_agent_command_environment(cmd, bin); + crate::cli_agent_runtime::apply_user_shell_env_vars_if_missing(cmd, CLAUDE_PROVIDER_ENV_KEYS); +} + fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String { if is_claude_auth_error(failure.stderr) { return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into(); @@ -1148,6 +1180,12 @@ mod tests { assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); } + #[test] + fn claude_provider_env_keys_include_reported_anthropic_overrides() { + assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_API_KEY"))); + assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_BASE_URL"))); + } + #[cfg(unix)] #[derive(Clone, Copy)] struct MockClaudeScript<'a>(&'a str); diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs index 7e2f1a04..79712a6f 100644 --- a/src-tauri/src/cli_agent_runtime.rs +++ b/src-tauri/src/cli_agent_runtime.rs @@ -5,8 +5,13 @@ use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; +mod shell_env; mod windows_cmd_shim; +pub(crate) use shell_env::{ + apply_user_shell_env_vars_if_missing, env_value_from_process_or_user_shell, EnvName, +}; + #[derive(Debug, Clone, Deserialize)] pub struct AgentStreamRequest { pub message: String, diff --git a/src-tauri/src/cli_agent_runtime/shell_env.rs b/src-tauri/src/cli_agent_runtime/shell_env.rs new file mode 100644 index 00000000..cd6a1a29 --- /dev/null +++ b/src-tauri/src/cli_agent_runtime/shell_env.rs @@ -0,0 +1,238 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EnvName<'a>(&'a str); + +impl<'a> EnvName<'a> { + pub(crate) fn new(raw: &'a str) -> Option { + is_valid_name(raw).then_some(Self(raw)) + } + + pub(crate) const fn trusted(raw: &'a str) -> Self { + Self(raw) + } + + pub(crate) fn as_str(self) -> &'a str { + self.0 + } +} + +pub(crate) fn apply_user_shell_env_vars_if_missing(command: &mut Command, names: &[EnvName<'_>]) { + let missing = valid_unique_names(names) + .into_iter() + .filter(|name| !process_has_value(name) && !command_has_value(command, name)) + .collect::>(); + for binding in user_shell_bindings(&missing) { + command.env(binding.name, binding.value); + } +} + +pub(crate) fn env_value_from_process_or_user_shell(name: EnvName<'_>) -> Option { + process_value(name).or_else(|| user_shell_value(name)) +} + +#[derive(Debug, PartialEq, Eq)] +struct EnvBinding { + name: String, + value: String, +} + +fn process_has_value(name: &EnvName<'_>) -> bool { + std::env::var_os(name.as_str()).is_some_and(|value| !value.is_empty()) +} + +fn command_has_value(command: &Command, name: &EnvName<'_>) -> bool { + command.get_envs().any(|(key, value)| { + key == OsStr::new(name.as_str()) && value.is_some_and(|value| !value.is_empty()) + }) +} + +fn process_value(name: EnvName<'_>) -> Option { + std::env::var(name.as_str()) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn user_shell_value(name: EnvName<'_>) -> Option { + user_shell_bindings(&[name]) + .into_iter() + .find_map(|binding| (binding.name == name.as_str()).then_some(binding.value)) +} + +fn user_shell_bindings(names: &[EnvName<'_>]) -> Vec { + let names = valid_unique_names(names); + if names.is_empty() { + return Vec::new(); + } + user_shell_bindings_for_platform(&names) +} + +fn valid_unique_names<'a>(names: &[EnvName<'a>]) -> Vec> { + let mut unique = Vec::new(); + for name in names.iter().copied() { + if !unique.iter().any(|existing| existing == &name) { + unique.push(name); + } + } + unique +} + +fn is_valid_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +#[cfg(unix)] +fn user_shell_bindings_for_platform(names: &[EnvName<'_>]) -> Vec { + shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| user_shell_bindings_from_shell(&shell, names)) + .unwrap_or_default() +} + +#[cfg(not(unix))] +fn user_shell_bindings_for_platform(_names: &[EnvName<'_>]) -> Vec { + Vec::new() +} + +#[cfg(unix)] +fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option> { + let output = crate::hidden_command(shell) + .arg("-lc") + .arg(shell_probe_script(shell, names)) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let bindings = parse_probe_output(&String::from_utf8_lossy(&output.stdout), names); + (!bindings.is_empty()).then_some(bindings) +} + +#[cfg(unix)] +fn shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +#[cfg(unix)] +fn shell_probe_script(shell: &Path, names: &[EnvName<'_>]) -> String { + format!( + "{}\nfor name in {}; do\n value=$(printenv \"$name\" 2>/dev/null || true)\n if [ -n \"$value\" ]; then\n printf '{}%s=%s\\n' \"$name\" \"$value\"\n fi\ndone\n", + rc_source_command(shell), + joined_names(names), + OUTPUT_PREFIX + ) +} + +fn joined_names(names: &[EnvName<'_>]) -> String { + names + .iter() + .map(|name| name.as_str()) + .collect::>() + .join(" ") +} + +#[cfg(unix)] +fn rc_source_command(shell: &Path) -> &'static str { + let name = shell + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + if name.contains("zsh") { + return "if [ -n \"${ZDOTDIR:-}\" ] && [ -r \"${ZDOTDIR}/.zshrc\" ]; then . \"${ZDOTDIR}/.zshrc\" >/dev/null 2>&1 || true; elif [ -r \"$HOME/.zshrc\" ]; then . \"$HOME/.zshrc\" >/dev/null 2>&1 || true; fi"; + } + if name.contains("bash") { + return "if [ -r \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\" >/dev/null 2>&1 || true; fi"; + } + "" +} + +struct ProbeOutput<'a>(&'a str); + +struct ProbeLine<'a>(&'a str); + +fn parse_probe_output(stdout: &str, names: &[EnvName<'_>]) -> Vec { + ProbeOutput(stdout) + .0 + .lines() + .filter_map(|line| parse_probe_line(ProbeLine(line), names)) + .collect() +} + +fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option { + let (name, value) = line.0.strip_prefix(OUTPUT_PREFIX)?.split_once('=')?; + let name = EnvName::new(name)?; + let value = value.trim(); + (names.iter().any(|expected| expected == &name) && !value.is_empty()).then(|| EnvBinding { + name: name.as_str().to_string(), + value: value.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let shell = dir.path().join("zsh"); + let zshrc = dir.path().join(".zshrc"); + std::fs::write( + &shell, + "#!/bin/sh\nexport HOME=$(dirname \"$0\")\nexec /bin/sh -c \"$2\"\n", + ) + .unwrap(); + std::fs::write( + &zshrc, + "export ANTHROPIC_API_KEY=from-zshrc\nexport ANTHROPIC_BASE_URL=https://proxy.example.test\n", + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let values = user_shell_bindings_from_shell( + &shell, + &[ + EnvName::trusted("ANTHROPIC_API_KEY"), + EnvName::trusted("ANTHROPIC_BASE_URL"), + EnvName::trusted("IGNORED_SECRET"), + ], + ) + .expect("zshrc exports should be readable"); + + assert_eq!( + values, + vec![ + EnvBinding { + name: "ANTHROPIC_API_KEY".to_string(), + value: "from-zshrc".to_string(), + }, + EnvBinding { + name: "ANTHROPIC_BASE_URL".to_string(), + value: "https://proxy.example.test".to_string(), + }, + ] + ); + } +}