From 89a0bf17e32cecd7b5be9ee101fcde507c4fe13a Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5 (claude-opus-5-5)" Date: Sun, 1 Nov 2026 09:36:20 +0100 Subject: [PATCH] feat(launch): read unset profile secrets through secretspec A launch profile's from_env key that the shell leaves unset is read with secretspec get from a manifest: the new secrets config key, RUNE_SECRETS, or ~/.config/rune/secretspec.toml when it exists and no env file is named. An empty secrets value turns the manifest off. With a manifest the env file is not read, so a stale ~/.env cannot answer for a failed read. Without one, the env file works as before. Each read passes a reason and the caller fields, so secretspec's audit log names the profile and the key. A missing manifest, a missing binary, or a failed read stops the launch with the key, the manifest, and a cause from a fixed list. Secretspec's own report is never repeated: a dotenv parse error quotes the failing line, which can hold a value. The SecretSource trait leaves room for the SDK. Change launch-secret-declaration-lookup. Co-Authored-By: Martin Zeman --- CHANGELOG.md | 10 + config.example.yaml | 5 + docs/Manual Testing.md | 5 +- .../launch-secret-declaration-lookup/adr.md | 52 ++++ .../design.md | 39 +++ .../proposal.md | 34 +++ .../pull-request.md | 24 ++ .../launch-secret-declaration-lookup/spec.md | 76 ++++++ .../launch-secret-declaration-lookup/tasks.md | 17 ++ docs/config-reference.json | 5 + src/cli/launch/mod.rs | 44 +++- src/cli/launch/secrets.rs | 232 ++++++++++++++++++ src/cli/launch/tests.rs | 168 ++++++++++++- src/cli/mod.rs | 2 +- src/cli/ontology.rs | 2 +- src/ontology.rs | 55 ++++- src/ontology/tests.rs | 50 ++++ 17 files changed, 803 insertions(+), 17 deletions(-) create mode 100644 docs/changes/launch-secret-declaration-lookup/adr.md create mode 100644 docs/changes/launch-secret-declaration-lookup/design.md create mode 100644 docs/changes/launch-secret-declaration-lookup/proposal.md create mode 100644 docs/changes/launch-secret-declaration-lookup/pull-request.md create mode 100644 docs/changes/launch-secret-declaration-lookup/specs/launch-secret-declaration-lookup/spec.md create mode 100644 docs/changes/launch-secret-declaration-lookup/tasks.md create mode 100644 src/cli/launch/secrets.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3da960..d87ebe94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [0.2.0]: https://github.com/runedeck/rune/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/runedeck/rune/releases/tag/v0.1.0 +## [Unreleased] + +### Added + +- Read an unset `from_env` key through secretspec from the manifest `secrets` or `RUNE_SECRETS` names, or from `~/.config/rune/secretspec.toml` when no env file is named. + +### Changed + +- Skip the env file when a secrets manifest applies. A failed read names the key and the manifest, and never repeats secretspec's report, which can quote a value. + ## [0.6.0] - 2026-10-26 ### Added diff --git a/config.example.yaml b/config.example.yaml index a2d79175..5d0c60f0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,6 +3,11 @@ # Rune already ships sol@claude and grok@claude with these localhost # CLIProxyAPI defaults. Repeating a profile here replaces that built-in profile, # which makes this file a starting point for endpoint or model overrides. +# +# A `from_env` key the shell does not export is read through secretspec from +# the manifest below. Without the key, Rune uses ~/.config/rune/secretspec.toml +# when that file exists, and otherwise the env file (key `env`, default ~/.env). +# secrets: ~/.config/rune/secretspec.toml launch: models: sol: diff --git a/docs/Manual Testing.md b/docs/Manual Testing.md index 5c62b3b5..0e5af0f1 100644 --- a/docs/Manual Testing.md +++ b/docs/Manual Testing.md @@ -808,7 +808,7 @@ rune launch nonexistent@claude # error listing known profiles rune launch claude -- --version # real execution: args after -- pass through ``` -The invocation is `[profile@]`, user@host style: `sol@claude` is profile `sol` running at tool `claude`. Profiles live under `launch.profiles` in `~/.config/rune/config.yaml`. Environment values support `from_env` references so secrets stay out of config. An unset process variable falls back to the env file (`rune config set env `, default `~/.env`). +The invocation is `[profile@]`, user@host style: `sol@claude` is profile `sol` running at tool `claude`. Profiles live under `launch.profiles` in `~/.config/rune/config.yaml`. Environment values support `from_env` references so secrets stay out of config. An unset process variable is read through `secretspec get` from the secrets manifest (`rune config set secrets `, default `~/.config/rune/secretspec.toml` when it exists). Without a manifest it falls back to the env file (`rune config set env `, default `~/.env`). Model routes live under `launch.models`. A profile selects one route with `model`, which keeps the provider model and context settings together. Start the local translating proxy used by the Sol and Lumo profiles, then configure the routes and profiles: @@ -846,7 +846,8 @@ launch: ```sh rune launch rune launch sol@claude --dry-run -env -u CLIPROXY_API_KEY RUNE_ENV=/dev/null rune launch sol@claude --dry-run +env -u CLIPROXY_API_KEY RUNE_ENV=/dev/null RUNE_SECRETS=/nonexistent/secretspec.toml rune launch sol@claude --dry-run +env -u CLIPROXY_API_KEY rune launch sol@claude --dry-run rune launch sol@claude ``` diff --git a/docs/changes/launch-secret-declaration-lookup/adr.md b/docs/changes/launch-secret-declaration-lookup/adr.md new file mode 100644 index 00000000..45dda3ad --- /dev/null +++ b/docs/changes/launch-secret-declaration-lookup/adr.md @@ -0,0 +1,52 @@ +--- +title: "Launch secrets are read through the secretspec binary" +description: "A from_env reference that the process environment leaves unset is read with the secretspec binary against one manifest; the env file is the fallback only on a machine without a manifest, and a trait keeps room for the SDK." +type: adr +category: architecture +tags: + - cli + - launch + - secrets +status: proposed +created: 2026-11-01 +updated: 2026-11-01 +author: "@N4M3Z" +project: cli +related: + - "CLI-0021 Launch Profile Composition" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: ["claude-opus-5-5@claude"] +informed: [] +upstream: + - https://github.com/cachix/secretspec +change: launch-secret-declaration-lookup +--- + +# Launch secrets are read through the secretspec binary + +## Context and Problem Statement + +Launch profiles keep credentials out of config with `from_env` references. An unset reference falls back to a plaintext env file. The owner wants every secret in the stack declared once and held in a real secret store: the macOS Keychain first, then Proton Pass and the `pass` store, with dotenv still supported. SecretSpec does that for any project that carries a `secretspec.toml` ([SecretSpec][SECRETSPEC]). The question is how rune reads through it. + +## Considered Options + +1. Run `secretspec get` for each unset key, against one manifest. +2. Link the secretspec Rust SDK and resolve in process. +3. Wrap the launched harness in `secretspec run`, which exports every declared secret into the child. +4. Keep the env file and document secretspec as a separate shell step. + +## Decision Outcome + +Option 1. When a manifest applies, the launcher MUST read each unset `from_env` key with `secretspec get` against that manifest, MUST pass a reason and the caller fields, and MUST NOT read the env file. Without a manifest, the env file stays the fallback. The `SecretSource` trait keeps option 2 open as a later implementation behind a cargo feature. + +Option 2 adds the SDK and its provider clients to every build of rune, for behavior the binary already has. Option 3 exports secrets under their declared names, but a profile maps a source key to a different target key (`ANTHROPIC_AUTH_TOKEN` from `CLIPROXY_API_KEY`), and it would put every declared secret into the harness when the profile names one or two. Option 4 leaves the plaintext file as the primary store. + +## Consequences + +- Launch credentials can live in the Keychain, Proton Pass, or `pass`. The manifest's provider chain decides, and the owner changes it without a rune release. +- Rune needs the `secretspec` binary on PATH when a manifest applies. A missing binary stops the launch with a message that names it. +- Each read is visible in secretspec's audit log with the launch that made it. +- The env file reader stays for machines without a manifest. It is a second code path to keep until the owner retires `~/.env`. + +[SECRETSPEC]: https://github.com/cachix/secretspec diff --git a/docs/changes/launch-secret-declaration-lookup/design.md b/docs/changes/launch-secret-declaration-lookup/design.md new file mode 100644 index 00000000..5558596b --- /dev/null +++ b/docs/changes/launch-secret-declaration-lookup/design.md @@ -0,0 +1,39 @@ +# Launch secret resolution design + +## Approach + +The launch resolver keeps its first step: a `from_env` reference that the process environment sets wins. The second step becomes a choice made once per launch. When a secretspec manifest applies, rune runs `secretspec get ` against it for each unset key. When no manifest applies, rune reads the env file as before. The two sources never mix, so a stale `~/.env` cannot answer for a key that the manifest's provider chain failed to read. + +The env file still works through the manifest. A manifest can end its provider chain with `dotenv://~/.env`, and secretspec then reads the file itself. That keeps the owner's existing keys usable while they move into the Keychain. + +## Structure + +- `src/ontology.rs`: `Config.secrets` and `ResolvedConfig.secrets`, resolved from `RUNE_SECRETS`, then config. `ResolvedConfig::secrets_manifest_in(config_dir)` returns a named path whether or not it exists, so a typo fails at the first read, and an empty name turns the manifest off. With nothing named, a named env file wins, and otherwise the default `secretspec.toml` in the config directory applies when it exists. +- `src/cli/ontology.rs`: `secrets` joins the scalar keys, so `rune config set secrets ` works. +- `src/cli/launch/secrets.rs`: the `SecretSource` trait and `SecretspecBinary`, which builds the argument list, runs the binary with stdin closed, and returns stdout byte for byte. A failure becomes one cause from a fixed list. Secretspec's own report never reaches rune's message: a dotenv parse error quotes the failing line, which can hold another key's value, and `rune run --json` writes the message to its output file. +- `src/cli/launch/mod.rs`: `SecretFallback` selects the manifest or the env file. `apply_profile_env` keeps one value per key for the launch, so two profile values that name one key cause one read. + +## Commands + +| Requirement | Command and behavior | +| --- | --- | +| Credentials read through the secret manager | `secretspec get --file ` for each `from_env` key the process environment leaves unset | +| Process environment first | `std::env::var()` before any lookup | +| Named declaration | config key `secrets`, environment override `RUNE_SECRETS`, `rune config set secrets ` | +| Default declaration | `~/.config/rune/secretspec.toml` when it exists and neither `env` nor `RUNE_ENV` is set, else the env file (default `~/.env`). `RUNE_SECRETS=` turns the manifest off | +| Failed read stops the launch | non-zero exit from `secretspec get`, a missing binary, or a missing manifest returns an error before spawn. The cause comes from `failure_cause`, and the message points at `secretspec check --file --explain` | +| Read reason recorded | `--reason "rune resolves the launch profile and reads for its environment" --caller rune --caller-version --caller-operation resolve-profile` | +| One read per credential | a per-launch map from key to value in `apply_profile_env` | + +## Linking the SDK later + +The owner asked for the binary now and the library prepared. `SecretSource` is the seam. A second implementation that calls the secretspec Rust SDK fits behind a cargo feature without a change to `apply_profile_env`. This change adds no dependency: the SDK pulls every provider client into the build, and the binary already gives the same chain, profile, and audit behavior. + +## Risks + +- A new process per key adds latency. A profile names one or two keys, and a Keychain read is fast. The per-launch map removes repeats. +- SecretSpec requires a reason from a process that it classifies as an agent ([require_reason][REASON]). Rune always passes one, so `rune run` from inside a harness does not fail on the policy. +- `SECRETSPEC_PROFILE` and `SECRETSPEC_PROVIDER` in the parent environment reach the child and change what it reads. That is the documented secretspec behavior, and rune does not override it. +- A dry run and `--check` read the credentials too, because both resolve the full plan. Their output redacts credential values, as before. + +[REASON]: https://secretspec.dev/reference/configuration/#requiring-a-reason-for-secret-access diff --git a/docs/changes/launch-secret-declaration-lookup/proposal.md b/docs/changes/launch-secret-declaration-lookup/proposal.md new file mode 100644 index 00000000..a85a0dbd --- /dev/null +++ b/docs/changes/launch-secret-declaration-lookup/proposal.md @@ -0,0 +1,34 @@ +--- +adr: docs/changes/launch-secret-declaration-lookup/adr.md +status: proposed +decisions: ["Launch secrets are read through the secretspec binary"] +--- + +# Launch secret resolution + +## Why + +A launch profile names its credential by reference, for example `ANTHROPIC_AUTH_TOKEN: { from_env: CLIPROXY_API_KEY }`. When the shell does not export the key, rune reads it from a plaintext env file, `~/.env` by default. Every proxy client key on the owner's machine lives in that file today. + +SecretSpec declares the secrets a project needs in one TOML file and reads them from a chain of providers: the macOS Keychain, Proton Pass, the `pass` store, a dotenv file, and others ([SecretSpec][SECRETSPEC]). Reading launch credentials through it moves them out of plaintext without a second resolver in rune. + +## What Changes + +- A new config key `secrets` (environment override `RUNE_SECRETS`) names a secretspec manifest. With no key set, rune uses `~/.config/rune/secretspec.toml` when that file exists. +- When a manifest applies, a `from_env` reference that the process environment leaves unset is read with `secretspec get` against that manifest. The env file is not read. +- Without a manifest, a launch behaves as before: process environment, then the env file. +- Each read passes a reason and caller fields, so secretspec's audit log names the launch and the key. +- A missing manifest, a missing `secretspec` binary, or a failed read stops the launch with the key, the manifest path, and secretspec's reason. No value is printed. +- `rune config set secrets ` and `rune config` list the new key. The launch help text describes the order. + +## Capabilities + +- launch-secret-declaration-lookup (new) + +## Impact + +- `src/ontology.rs`, `src/cli/ontology.rs`, `src/cli/launch/mod.rs`, a new `src/cli/launch/secrets.rs`, their tests, `docs/config-reference.json`, `config.example.yaml`, the launch help text, and `CHANGELOG.md`. +- `rune run` resolves its plan through the launch resolver, so it gains the same order. +- The owner's machine needs the manifest at `~/.config/rune/secretspec.toml`. The provision repository deploys it, and it lies outside this repository. + +[SECRETSPEC]: https://github.com/cachix/secretspec diff --git a/docs/changes/launch-secret-declaration-lookup/pull-request.md b/docs/changes/launch-secret-declaration-lookup/pull-request.md new file mode 100644 index 00000000..c5ccdcc3 --- /dev/null +++ b/docs/changes/launch-secret-declaration-lookup/pull-request.md @@ -0,0 +1,24 @@ +`rune launch` and `rune run` read a profile's `from_env` key that the shell leaves unset through secretspec, from a manifest the new `secrets` config key names. + +## Plan + +Change `launch-secret-declaration-lookup`: proposal, design, one ADDED delta (`launch-secret-declaration-lookup`), tasks, and `adr.md` under `docs/changes/launch-secret-declaration-lookup/`. + +## Changes + +- Add the `secrets` config key and the `RUNE_SECRETS` override (`src/ontology.rs`). With neither set and no env file named, use `~/.config/rune/secretspec.toml` when it exists. An empty value turns the manifest off. `rune config set secrets ` works. +- Add `src/cli/launch/secrets.rs`: the `SecretSource` trait and `SecretspecBinary`, which runs `secretspec get --file ` with a reason and `--caller rune`, and returns stdout byte for byte. +- Read through the manifest in `apply_profile_env` when one applies, and never read the env file then. Without a manifest, keep the env file fallback. Read each key once per launch. +- Stop the launch on a missing manifest, a missing binary, or a failed read, with the key, the manifest path, and a cause from a fixed list. Secretspec's own error text is never repeated, because a dotenv parse error quotes a stored value. +- Update the launch help text, `config.example.yaml`, `docs/config-reference.json`, `docs/Manual Testing.md`, and `CHANGELOG.md`. + +## Testing + +- `cargo test --workspace --all-features`: every test binary passes. New tests cover the manifest read, the environment winning, one read per key, a failed read, a missing manifest, the argument list, and a fake `secretspec` binary. +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` and `cargo fmt --check` clean. +- `rune spec validate launch-secret-declaration-lookup` passes. +- One live dry run of `sol@claude` with no shell variable and no env file: secretspec's audit log shows the Keychain served `CLIPROXY_API_KEY` with rune's reason and caller. + +## Release Notes + +- `rune launch` and `rune run` read unset `from_env` keys through secretspec from `~/.config/rune/secretspec.toml`, or the manifest that `secrets` or `RUNE_SECRETS` names. Without a manifest, the env file works as before. diff --git a/docs/changes/launch-secret-declaration-lookup/specs/launch-secret-declaration-lookup/spec.md b/docs/changes/launch-secret-declaration-lookup/specs/launch-secret-declaration-lookup/spec.md new file mode 100644 index 00000000..a11b2c89 --- /dev/null +++ b/docs/changes/launch-secret-declaration-lookup/specs/launch-secret-declaration-lookup/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Credentials read through the secret manager + +When a launch profile names a credential that the process environment does not set, and a secret declaration applies, the launcher MUST read the credential through the secret manager against that declaration and MUST NOT read the environment file. A secret declaration is the file that lists the secrets a launch may need and the providers that hold them. + +#### Scenario: Credential absent from the environment + +- **WHEN** a profile names a credential that the process environment does not set and a secret declaration applies +- **THEN** the launcher reads the credential through the secret manager +- **AND** the environment file stays unread + +### Requirement: Process environment first + +The launcher MUST take a credential from the process environment when the environment sets it, and MUST NOT ask the secret manager for that credential. + +#### Scenario: Credential set in the environment + +- **WHEN** the process environment sets a credential that a profile names +- **THEN** the launch uses the environment value +- **AND** the secret manager receives no request for that credential + +### Requirement: Named declaration + +The launcher MUST use the secret declaration that the user configuration or the process environment names, and the process environment MUST win over the configuration. When the named declaration does not exist, the launcher MUST stop before the harness starts and MUST report the path. + +#### Scenario: Named declaration missing + +- **WHEN** the configuration names a secret declaration that does not exist and a profile needs a credential from it +- **THEN** the launch stops before the harness starts +- **AND** the report names the credential and the missing path + +### Requirement: Default declaration + +When nothing names a secret declaration and the user names no environment file, the launcher MUST use the declaration in the user configuration directory if that file exists. Otherwise it MUST read an unset credential from the environment file, as it did before secret declarations existed. An empty declaration name MUST turn the declaration off. + +#### Scenario: No declaration on the machine + +- **WHEN** nothing names a secret declaration and the configuration directory holds none +- **THEN** the launcher reads an unset credential from the environment file + +#### Scenario: Named environment file beside a default declaration + +- **WHEN** the user names an environment file, names no secret declaration, and the configuration directory holds one +- **THEN** the launcher reads an unset credential from the named environment file + +### Requirement: Failed read stops the launch + +When the secret manager cannot supply a credential, the launcher MUST stop before the harness starts. The report MUST name the credential, the declaration path, and a cause from a fixed list, and MUST NOT contain a credential value. The report MUST NOT repeat the secret manager's own error output, because that output can quote a stored value. + +#### Scenario: Secret manager refuses + +- **WHEN** the secret manager reports that it cannot supply a credential +- **THEN** the launch stops before the harness starts +- **AND** the report names the credential, the declaration, and the cause +- **AND** no line of the secret manager's error output appears in the report + +### Requirement: Read reason recorded + +Every read through the secret manager MUST carry a reason that names the launch profile and the credential, and MUST identify rune as the caller. The secret manager's audit log then shows which profile read which credential. + +#### Scenario: Audit entry for a launch + +- **WHEN** the launcher reads a credential through the secret manager +- **THEN** the request carries a reason that names the launch profile and the credential +- **AND** the request names rune as the caller + +### Requirement: One read per credential + +The launcher MUST read each credential at most once per launch, even when several profile values name the same credential. + +#### Scenario: Two values name one credential + +- **WHEN** two profile values name the same credential +- **THEN** the secret manager receives one request for it +- **AND** both values carry the same credential diff --git a/docs/changes/launch-secret-declaration-lookup/tasks.md b/docs/changes/launch-secret-declaration-lookup/tasks.md new file mode 100644 index 00000000..afd3f8ea --- /dev/null +++ b/docs/changes/launch-secret-declaration-lookup/tasks.md @@ -0,0 +1,17 @@ +## 1. Implementation + +- [x] 1.1 `secrets` config key with the `RUNE_SECRETS` override, `secrets_manifest()`, and `rune config set secrets` +- [x] 1.2 `src/cli/launch/secrets.rs`: `SecretSource` and `SecretspecBinary` with the reason and caller arguments +- [x] 1.3 `SecretFallback` in `apply_profile_env`: environment first, then the manifest or the env file, one read per key +- [x] 1.4 Launch help text, `config.example.yaml`, `docs/config-reference.json`, and `CHANGELOG.md` + +## 2. Verification + +- [x] 2.1 `cargo test` covers each scenario: a fake secret source and a fake `secretspec` binary +- [x] 2.2 One live dry run against the owner's Keychain through `~/.config/rune/secretspec.toml`: the audit log shows `provider: keyring`, the reason, and `caller: rune` +- [ ] 2.3 Recorded proof per scenario under `docs/proofs/launch-secret-declaration-lookup/` +- [x] 2.4 `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --check`, `rune spec validate launch-secret-declaration-lookup` + +## 3. Record + +- [ ] 3.1 Archive moves `adr.md` to `docs/decisions/` with the next free CLI number diff --git a/docs/config-reference.json b/docs/config-reference.json index 27c09bc4..19909a69 100644 --- a/docs/config-reference.json +++ b/docs/config-reference.json @@ -1049,6 +1049,11 @@ "key": "ontology.work", "type": "string | null" }, + { + "default": null, + "key": "secrets", + "type": "string | null" + }, { "default": null, "key": "setup", diff --git a/src/cli/launch/mod.rs b/src/cli/launch/mod.rs index ce6f9d78..17efe7e8 100644 --- a/src/cli/launch/mod.rs +++ b/src/cli/launch/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod check; +pub(crate) mod secrets; use crate::cli::dispatch; use crate::cli::surface; @@ -168,7 +169,16 @@ fn resolve_with_config( options.args = args; } if let Some(profile) = &profile { - apply_profile_env(profile, &mut plan, context.config.env_file().as_deref())?; + let manifest = context.config.secrets_manifest(); + let env_file = context.config.env_file(); + let secretspec = manifest + .as_deref() + .map(|path| secrets::SecretspecBinary::new(path, invocation)); + let fallback = match &secretspec { + Some(source) => SecretFallback::Manifest(source), + None => SecretFallback::EnvFile(env_file.as_deref()), + }; + apply_profile_env(profile, &mut plan, &fallback)?; } // A wrapped command unsets the parent session itself, from the whole // tables: a tmux pane takes the tmux server's environment, not rune's. @@ -780,19 +790,41 @@ fn validate_model(alias: &str, model: &LaunchModel) -> Result<(), String> { Ok(()) } +/// Where a `from_env` reference is read when the process environment +/// leaves it unset: a secrets manifest when one applies, else the env file. +/// The two never mix, so a stale env file cannot answer for a manifest. +pub(crate) enum SecretFallback<'a> { + Manifest(&'a dyn secrets::SecretSource), + EnvFile(Option<&'a Path>), +} + fn apply_profile_env( profile: &ontology::LaunchProfile, plan: &mut LaunchPlan, - env_file: Option<&Path>, + fallback: &SecretFallback<'_>, ) -> Result<(), String> { let mut file_values: Option> = None; + let mut read: HashMap = HashMap::new(); let mut keys: Vec<&String> = profile.env.keys().collect(); keys.sort_unstable(); for key in keys { let value = match &profile.env[key] { ontology::ProfileEnvValue::Literal(value) => value.clone(), ontology::ProfileEnvValue::FromEnv { from_env } => { - resolve_env_reference(from_env, env_file, &mut file_values)? + if let Ok(value) = std::env::var(from_env) { + value + } else if let Some(value) = read.get(from_env) { + value.clone() + } else { + let value = match fallback { + SecretFallback::Manifest(source) => source.get(from_env)?, + SecretFallback::EnvFile(env_file) => { + resolve_env_reference(from_env, *env_file, &mut file_values)? + } + }; + read.insert(from_env.clone(), value.clone()); + value + } } }; plan.env.push((OsString::from(key), OsString::from(value))); @@ -800,16 +832,12 @@ fn apply_profile_env( Ok(()) } -/// The process environment wins; an unset variable falls back to the env -/// file, parsed once per launch. +/// An unset variable read from the env file, parsed once per launch. fn resolve_env_reference( name: &str, env_file: Option<&Path>, file_values: &mut Option>, ) -> Result { - if let Ok(value) = std::env::var(name) { - return Ok(value); - } let Some(path) = env_file else { return Err(format!( "profile references unset environment variable {name}" diff --git a/src/cli/launch/secrets.rs b/src/cli/launch/secrets.rs new file mode 100644 index 00000000..6576b31b --- /dev/null +++ b/src/cli/launch/secrets.rs @@ -0,0 +1,232 @@ +//! Secret lookups for launch profiles. +//! +//! A `from_env` reference that the process environment leaves unset is read +//! from a secretspec manifest. Rune runs the `secretspec` binary for each +//! key, so the provider chain, the profile, and the audit log stay +//! secretspec's own. [`SecretSource`] is the seam where a linked secretspec +//! SDK would replace the binary. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// The program rune runs to read a secret from a manifest. +pub(crate) const SECRETSPEC_PROGRAM: &str = "secretspec"; + +/// One way to read a named secret. An error names the key and the source, +/// never a value. +pub(crate) trait SecretSource { + fn get(&self, key: &str) -> Result; +} + +/// Reads each secret with `secretspec get`, against one manifest. +pub(crate) struct SecretspecBinary { + program: OsString, + manifest: PathBuf, + invocation: String, +} + +impl SecretspecBinary { + pub(crate) fn new(manifest: &Path, invocation: &str) -> Self { + Self::with_program(OsString::from(SECRETSPEC_PROGRAM), manifest, invocation) + } + + pub(crate) fn with_program(program: OsString, manifest: &Path, invocation: &str) -> Self { + Self { + program, + manifest: manifest.to_path_buf(), + invocation: invocation.to_string(), + } + } + + /// The arguments for one lookup. The reason and the caller fields go + /// to secretspec's audit log; secretspec refuses an agent session that + /// gives no reason. `rune launch`, `rune run`, a dry run, and a check + /// all resolve a profile this way, so the reason names the profile + /// resolution and not one command. + pub(crate) fn arguments(&self, key: &str) -> Vec { + vec![ + OsString::from("get"), + OsString::from(key), + OsString::from("--file"), + self.manifest.clone().into_os_string(), + OsString::from("--reason"), + OsString::from(format!( + "rune resolves the {} launch profile and reads {key} for its environment", + self.invocation + )), + OsString::from("--caller"), + OsString::from("rune"), + OsString::from("--caller-version"), + OsString::from(env!("CARGO_PKG_VERSION")), + OsString::from("--caller-operation"), + OsString::from("resolve-profile"), + ] + } +} + +impl SecretSource for SecretspecBinary { + fn get(&self, key: &str) -> Result { + let manifest = self.manifest.display(); + if !self.manifest.is_file() { + return Err(format!( + "profile references {key}, unset in the environment, and the secrets manifest {manifest} does not exist" + )); + } + let output = Command::new(&self.program) + .args(self.arguments(key)) + .stdin(Stdio::null()) + .output() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + format!( + "profile references {key}, unset in the environment; reading it from {manifest} needs {} on PATH", + self.program.to_string_lossy() + ) + } else { + format!( + "profile references {key}, unset in the environment; cannot run {}: {error}", + self.program.to_string_lossy() + ) + } + })?; + if !output.status.success() { + let cause = failure_cause(&String::from_utf8_lossy(&output.stderr)); + return Err(format!( + "profile references {key}, unset in the environment and unreadable from {manifest}: {cause}; `secretspec check --file {manifest} --explain` prints a trace without values" + )); + } + String::from_utf8(output.stdout) + .map_err(|_| format!("secret {key} from {manifest} is not UTF-8")) + } +} + +/// A fixed description of a secretspec failure. The report itself never +/// reaches the caller: a provider error can quote the source line it failed +/// on, and a dotenv parse error quotes the value of another key. +fn failure_cause(stderr: &str) -> &'static str { + if stderr.contains("requires a reason") { + "secretspec wants a reason for this caller" + } else if stderr.contains("No provider backend configured") { + "secretspec has no provider; run `secretspec config global init`" + } else if stderr.contains("Failed to load secretspec configuration") { + "the manifest does not load" + } else if stderr.contains("syntax error") || stderr.contains("error parsing") { + "a provider in the manifest's chain failed to parse its store" + } else if stderr.contains("not found") { + "no provider in the manifest's chain holds it" + } else { + "secretspec failed" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failure_cause_never_repeats_the_report() { + let parse = "warning: primary provider dotenv:/x/.env failed: Dotenv error: error parsing '/x/.env':\n1 | OTHER_API_KEY=\"FAKE-LEAK-VALUE\n | ^\nError: × Failed to get secret\n ╰─▶ dotenv syntax error at line 1, column 15: unterminated '\"' quoted value\n 1 | OTHER_API_KEY=\"FAKE-LEAK-VALUE\n"; + let cause = failure_cause(parse); + assert!(!cause.contains("FAKE-LEAK-VALUE"), "{cause}"); + assert_eq!( + cause, + "a provider in the manifest's chain failed to parse its store" + ); + assert_eq!( + failure_cause("Error: × Failed to get secret\n ╰─▶ Secret 'KEY' not found\n"), + "no provider in the manifest's chain holds it" + ); + assert_eq!(failure_cause("anything else"), "secretspec failed"); + } + + #[test] + fn arguments_name_the_manifest_reason_and_caller() { + let source = SecretspecBinary::new(Path::new("/manifests/secretspec.toml"), "sol@claude"); + let arguments: Vec = source + .arguments("CLIPROXY_API_KEY") + .into_iter() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + &arguments[..4], + [ + "get", + "CLIPROXY_API_KEY", + "--file", + "/manifests/secretspec.toml" + ] + ); + let value_after = |flag: &str| { + arguments + .iter() + .position(|argument| argument == flag) + .map(|at| arguments[at + 1].clone()) + }; + assert_eq!( + value_after("--reason").as_deref(), + Some( + "rune resolves the sol@claude launch profile and reads CLIPROXY_API_KEY for its environment" + ) + ); + assert_eq!(value_after("--caller").as_deref(), Some("rune")); + assert_eq!( + value_after("--caller-operation").as_deref(), + Some("resolve-profile") + ); + } + + #[test] + fn missing_manifest_fails_before_running_anything() { + let source = SecretspecBinary::with_program( + OsString::from("/nonexistent/secretspec"), + Path::new("/nonexistent/secretspec.toml"), + "sol@claude", + ); + let error = source.get("KEY").unwrap_err(); + assert!(error.contains("does not exist"), "{error}"); + assert!(error.contains("KEY"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn binary_value_is_returned_exactly_and_failures_carry_no_report_text() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("secretspec.toml"); + std::fs::write( + &manifest, + "[project]\nname = \"probe\"\nrevision = \"1.0\"\n", + ) + .expect("manifest"); + let log = dir.path().join("argv.log"); + let program = dir.path().join("secretspec"); + std::fs::write( + &program, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nif [ \"$2\" = PRESENT ]; then printf 'fake-secret-value'; exit 0; fi\nprintf 'warning: audit\\n1 | OTHER=FAKE-LEAK-VALUE\\nError: x Failed to get secret\\n ╰─▶ Secret '\\''%s'\\'' not found\\n' \"$2\" >&2\nexit 1\n", + log.display() + ), + ) + .expect("fake secretspec"); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + let source = + SecretspecBinary::with_program(program.into_os_string(), &manifest, "sol@claude"); + assert_eq!(source.get("PRESENT").unwrap(), "fake-secret-value"); + let recorded = std::fs::read_to_string(&log).expect("argv log"); + assert!(recorded.starts_with("get\nPRESENT\n--file\n"), "{recorded}"); + + let error = source.get("ABSENT").unwrap_err(); + assert!(error.contains("ABSENT"), "{error}"); + assert!(error.contains(&manifest.display().to_string()), "{error}"); + assert!( + error.contains("no provider in the manifest's chain holds it"), + "{error}" + ); + assert!(error.contains("--explain"), "{error}"); + assert!(!error.contains("FAKE-LEAK-VALUE"), "{error}"); + assert!(!error.contains("warning"), "{error}"); + } +} diff --git a/src/cli/launch/tests.rs b/src/cli/launch/tests.rs index af5c7a52..b1446320 100644 --- a/src/cli/launch/tests.rs +++ b/src/cli/launch/tests.rs @@ -10,6 +10,7 @@ fn context(root: &Path, launch: Launch, extensions: Vec) -> LaunchConte config: ontology::ResolvedConfig { deck: None, env: None, + secrets: None, ontology: ontology::ResolvedOntology::default(), extensions, setup: None, @@ -258,6 +259,7 @@ fn resolved_launch_preserves_interactive_arguments_and_dry_run() { ontology::ResolvedConfig { deck: None, env: None, + secrets: None, ontology: ontology::ResolvedOntology::default(), extensions: Vec::new(), setup: None, @@ -940,7 +942,7 @@ fn profile_env_supports_literals_and_from_env_references() { ontology::ProfileEnvValue::Literal("claude-fable-5".to_string()), ); let mut plan = LaunchPlan::default(); - apply_profile_env(&profile, &mut plan, None).unwrap(); + apply_profile_env(&profile, &mut plan, &SecretFallback::EnvFile(None)).unwrap(); assert_eq!( plan.env, vec![( @@ -957,7 +959,7 @@ fn profile_env_supports_literals_and_from_env_references() { }, ); let mut plan = LaunchPlan::default(); - let error = apply_profile_env(&broken, &mut plan, None).unwrap_err(); + let error = apply_profile_env(&broken, &mut plan, &SecretFallback::EnvFile(None)).unwrap_err(); assert!(error.contains("RUNE_TEST_UNSET_VARIABLE"), "{error}"); } @@ -979,7 +981,12 @@ fn from_env_reference_falls_back_to_the_env_file() { }, ); let mut plan = LaunchPlan::default(); - apply_profile_env(&profile, &mut plan, Some(&env_path)).unwrap(); + apply_profile_env( + &profile, + &mut plan, + &SecretFallback::EnvFile(Some(&env_path)), + ) + .unwrap(); assert_eq!( plan.env, vec![(OsString::from("TARGET_KEY"), OsString::from("file-value"))] @@ -993,11 +1000,161 @@ fn from_env_reference_falls_back_to_the_env_file() { }, ); let mut plan = LaunchPlan::default(); - let error = apply_profile_env(&missing, &mut plan, Some(&env_path)).unwrap_err(); + let error = apply_profile_env( + &missing, + &mut plan, + &SecretFallback::EnvFile(Some(&env_path)), + ) + .unwrap_err(); assert!(error.contains("absent from"), "{error}"); assert!(error.contains("RUNE_TEST_ABSENT_EVERYWHERE"), "{error}"); } +/// A secret source that answers from a fixed table and counts its reads. +struct TableSource { + values: HashMap<&'static str, &'static str>, + reads: std::cell::Cell, +} + +impl secrets::SecretSource for TableSource { + fn get(&self, key: &str) -> Result { + self.reads.set(self.reads.get() + 1); + self.values + .get(key) + .map(|value| (*value).to_string()) + .ok_or_else(|| format!("profile references {key}, unreadable from the manifest")) + } +} + +fn from_env_profile(pairs: &[(&str, &str)]) -> ontology::LaunchProfile { + let mut profile = ontology::LaunchProfile::default(); + for (key, source) in pairs { + profile.env.insert( + (*key).to_string(), + ontology::ProfileEnvValue::FromEnv { + from_env: (*source).to_string(), + }, + ); + } + profile +} + +#[test] +fn manifest_value_fills_every_reference_with_one_read() { + let source = TableSource { + values: HashMap::from([("RUNE_TEST_MANIFEST_KEY", "manifest-value")]), + reads: std::cell::Cell::new(0), + }; + let profile = from_env_profile(&[ + ("ANTHROPIC_AUTH_TOKEN", "RUNE_TEST_MANIFEST_KEY"), + ("ANTHROPIC_API_KEY", "RUNE_TEST_MANIFEST_KEY"), + ]); + let mut plan = LaunchPlan::default(); + apply_profile_env(&profile, &mut plan, &SecretFallback::Manifest(&source)).unwrap(); + + assert_eq!( + plan.env, + vec![ + ( + OsString::from("ANTHROPIC_API_KEY"), + OsString::from("manifest-value") + ), + ( + OsString::from("ANTHROPIC_AUTH_TOKEN"), + OsString::from("manifest-value") + ), + ] + ); + assert_eq!(source.reads.get(), 1, "a key referenced twice is read once"); +} + +#[test] +fn launch_picks_the_named_manifest_or_the_named_env_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_path = dir.path().join("env"); + std::fs::write(&env_path, "RUNE_TEST_SELECTION_KEY=file-value\n").expect("env file"); + let mut launch = Launch::default(); + launch.profiles.insert( + "claude".to_string(), + [( + "picked".to_string(), + from_env_profile(&[("ANTHROPIC_AUTH_TOKEN", "RUNE_TEST_SELECTION_KEY")]), + )] + .into_iter() + .collect(), + ); + let named = |value: &str| ontology::ResolvedValue { + value: value.to_string(), + source: ontology::Source::Config, + }; + let config = |secrets: Option<&str>| ontology::ResolvedConfig { + env: Some(named(&env_path.display().to_string())), + secrets: secrets.map(named), + launch: launch.clone(), + ..ontology::ResolvedConfig::default() + }; + let resolve = |config| { + resolve_with_config( + "picked@claude", + &[], + dir.path().to_path_buf(), + dir.path().to_path_buf(), + config, + ) + }; + let file_token = |resolved: &ResolvedLaunch| { + resolved + .env + .iter() + .any(|(key, value)| key == "ANTHROPIC_AUTH_TOKEN" && value == "file-value") + }; + + // A named manifest wins over the named env file, even when it is missing. + let missing = dir.path().join("missing.toml"); + let error = resolve(config(Some(&missing.display().to_string()))).unwrap_err(); + assert!(error.message.contains("does not exist"), "{error}"); + assert!(!error.message.contains("file-value"), "{error}"); + + // An empty name turns the manifest off, and the env file answers. + assert!(file_token(&resolve(config(Some(""))).expect("empty name"))); + + // With no manifest named, the named env file beats the default manifest. + assert!(file_token(&resolve(config(None)).expect("env file named"))); +} + +#[test] +fn manifest_failure_stops_the_launch_with_the_reference() { + let source = TableSource { + values: HashMap::new(), + reads: std::cell::Cell::new(0), + }; + let profile = from_env_profile(&[("TARGET_KEY", "RUNE_TEST_MANIFEST_ABSENT")]); + let mut plan = LaunchPlan::default(); + let error = + apply_profile_env(&profile, &mut plan, &SecretFallback::Manifest(&source)).unwrap_err(); + assert!(error.contains("RUNE_TEST_MANIFEST_ABSENT"), "{error}"); + assert_eq!(plan.env, [] as [(OsString, OsString); 0]); +} + +#[test] +fn process_environment_wins_over_the_manifest() { + let source = TableSource { + values: HashMap::from([("PATH", "from-manifest")]), + reads: std::cell::Cell::new(0), + }; + let profile = from_env_profile(&[("SEARCH_PATH", "PATH")]); + let mut plan = LaunchPlan::default(); + apply_profile_env(&profile, &mut plan, &SecretFallback::Manifest(&source)).unwrap(); + assert_eq!( + plan.env, + vec![( + OsString::from("SEARCH_PATH"), + std::env::var_os("PATH").expect("PATH is set") + )] + ); + assert_eq!(source.reads.get(), 0); +} + #[test] fn env_file_parser_handles_export_quotes_and_comments() { let parsed = parse_env_file( @@ -1069,6 +1226,7 @@ fn endpoint_comes_from_the_plan_or_the_tools_base_url_key_in_the_env() { let config = || ontology::ResolvedConfig { deck: None, env: None, + secrets: None, ontology: ontology::ResolvedOntology::default(), extensions: Vec::new(), setup: None, @@ -1898,6 +2056,7 @@ fn launch_route_keys_include_the_settings_env_of_a_claude_profile() { ontology::ResolvedConfig { deck: None, env: None, + secrets: None, ontology: ontology::ResolvedOntology::default(), extensions: Vec::new(), setup: None, @@ -1959,6 +2118,7 @@ fn try_resolve_with_config_for_tests( ontology::ResolvedConfig { deck: None, env: None, + secrets: None, ontology: ontology::ResolvedOntology::default(), extensions: Vec::new(), setup: None, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3052c912..0aef84db 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -755,7 +755,7 @@ enum Command { /// Launch a coding tool with composable environment middleware #[command( - after_help = "LAUNCH OPTIONS:\n --with Middleware chain to apply in order\n --pxpipe Legacy sugar for --with pxpipe\n --direct Clear the configured/default middleware chain\n --tmux[=NAME] Wrap the launch in a tmux session\n --dry-run Print the resolved launch plan without spawning\n --check Ask each base URL for its model list and report every\n model the plan sends as served or missing; no spawn.\n Exit 0 served, 1 missing, 2 endpoint failure\n -- ARGS... Arguments passed to the launched tool\n\nPROFILES (~/.config/rune/config.yaml):\n launch:\n profiles:\n claude:\n sol: # rune launch sol@claude\n env:\n ANTHROPIC_BASE_URL: http://localhost:8317 # your Anthropic-compatible proxy\n ANTHROPIC_MODEL: gpt-5.6-sol\n # ANTHROPIC_API_KEY: { from_env: CLIPROXY_API_KEY }\n args: []\n with: []\n codex:\n deep:\n args: [\"-m\", \"gpt-5.6-sol\", \"-c\", \"model_reasoning_effort=xhigh\"]\n\n Env values are literals or { from_env: KEY } references; secrets stay\n out of config. Unset references fall back to the env file (config key\n env, default ~/.env). ollama profiles double as models: rune launch\n llama3@ollama\n\nCURSOR:\n rune launch cursor starts cursor-agent, because cursor on a Mac opens\n the editor; tools.cursor.binary names another binary. fable@cursor is\n built in and passes --model claude-fable-5-1-high; a configured\n profile or route of the same name replaces it." + after_help = "LAUNCH OPTIONS:\n --with Middleware chain to apply in order\n --pxpipe Legacy sugar for --with pxpipe\n --direct Clear the configured/default middleware chain\n --tmux[=NAME] Wrap the launch in a tmux session\n --dry-run Print the resolved launch plan without spawning\n --check Ask each base URL for its model list and report every\n model the plan sends as served or missing; no spawn.\n Exit 0 served, 1 missing, 2 endpoint failure\n -- ARGS... Arguments passed to the launched tool\n\nPROFILES (~/.config/rune/config.yaml):\n launch:\n profiles:\n claude:\n sol: # rune launch sol@claude\n env:\n ANTHROPIC_BASE_URL: http://localhost:8317 # your Anthropic-compatible proxy\n ANTHROPIC_MODEL: gpt-5.6-sol\n # ANTHROPIC_API_KEY: { from_env: CLIPROXY_API_KEY }\n args: []\n with: []\n codex:\n deep:\n args: [\"-m\", \"gpt-5.6-sol\", \"-c\", \"model_reasoning_effort=xhigh\"]\n\n Env values are literals or { from_env: KEY } references; secrets stay\n out of config. An unset reference is read through secretspec from the\n secrets manifest (config key secrets, default\n ~/.config/rune/secretspec.toml when it exists). Without a manifest it\n falls back to the env file (config key env, default ~/.env). ollama\n profiles double as models: rune launch llama3@ollama\n\nCURSOR:\n rune launch cursor starts cursor-agent, because cursor on a Mac opens\n the editor; tools.cursor.binary names another binary. fable@cursor is\n built in and passes --model claude-fable-5-1-high; a configured\n profile or route of the same name replaces it." )] Launch { /// Coding tool to launch, such as `claude` or `sol@claude` for a diff --git a/src/cli/ontology.rs b/src/cli/ontology.rs index 198b7373..adc14f65 100644 --- a/src/cli/ontology.rs +++ b/src/cli/ontology.rs @@ -64,7 +64,7 @@ pub fn set(key: &str, value: &str, json: bool) -> Result { Ok(0) } -const SCALAR_KEYS: [&str; 2] = ["deck", "env"]; +const SCALAR_KEYS: [&str; 3] = ["deck", "env", "secrets"]; fn unsupported_key(key: &str) -> String { format!( diff --git a/src/ontology.rs b/src/ontology.rs index c9930c6b..563d76a0 100644 --- a/src/ontology.rs +++ b/src/ontology.rs @@ -11,8 +11,15 @@ use std::path::{Path, PathBuf}; pub struct Config { pub deck: Option, /// Env file consulted when a launch profile's `from_env` reference is - /// unset in the process environment. Defaults to `~/.env`. + /// unset in the process environment and no secrets manifest applies. + /// Defaults to `~/.env`. pub env: Option, + /// Secretspec manifest consulted when a launch profile's `from_env` + /// reference is unset in the process environment. Defaults to + /// `~/.config/rune/secretspec.toml` when that file exists and no `env` + /// file is named. An empty value turns the manifest off. With a + /// manifest, the env file is not read. + pub secrets: Option, pub ontology: Ontology, pub extensions: Vec, pub launch: Launch, @@ -356,6 +363,7 @@ pub struct ResolvedValue { pub struct ResolvedConfig { pub deck: Option, pub env: Option, + pub secrets: Option, pub ontology: ResolvedOntology, pub extensions: Vec, pub setup: Option, @@ -379,8 +387,35 @@ impl ResolvedConfig { None => dirs::home_dir().map(|home| home.join(".env")), } } + + /// The secretspec manifest consulted for `from_env` fallback, looked up + /// beside the user config in `~/.config/rune`. + #[must_use] + pub fn secrets_manifest(&self) -> Option { + self.secrets_manifest_in(config_dir().ok().as_deref()) + } + + /// The secretspec manifest consulted for `from_env` fallback. A named + /// manifest is returned whether or not it exists, so a typo fails at + /// the first read, and an empty name turns the manifest off. With no + /// name, an env file the user named wins; otherwise the default + /// manifest in `config_dir` applies when that file exists. + #[must_use] + pub fn secrets_manifest_in(&self, config_dir: Option<&Path>) -> Option { + match &self.secrets { + Some(value) if value.value.is_empty() => None, + Some(value) => Some(expand_tilde(&value.value)), + None if self.env.is_some() => None, + None => config_dir + .map(|dir| dir.join(SECRETS_MANIFEST)) + .filter(|path| path.is_file()), + } + } } +/// The secretspec manifest a launch reads when config names none. +pub const SECRETS_MANIFEST: &str = "secretspec.toml"; + #[derive(Debug, Clone, Copy)] enum Key { Targets, @@ -585,6 +620,12 @@ pub fn fields(config: &ResolvedConfig) -> Vec { value: config.env.as_ref().map(|value| value.value.clone()), source: config.env.as_ref().map(|value| value.source), }); + fields.push(ResolvedField { + key: "secrets", + env: "RUNE_SECRETS", + value: config.secrets.as_ref().map(|value| value.value.clone()), + source: config.secrets.as_ref().map(|value| value.source), + }); fields.push(ResolvedField { key: "bench", env: "", @@ -730,6 +771,17 @@ fn resolve_config(config: &Config, env: &dyn Fn(&str) -> Option) -> Reso source: Source::Config, }) }); + let secrets = env("RUNE_SECRETS") + .map(|value| ResolvedValue { + value, + source: Source::Env, + }) + .or_else(|| { + config.secrets.as_ref().map(|value| ResolvedValue { + value: value.clone(), + source: Source::Config, + }) + }); let ontology = ResolvedOntology { targets: resolve_key(Key::Targets, &config.ontology, env), skeleton: resolve_key(Key::Skeleton, &config.ontology, env), @@ -752,6 +804,7 @@ fn resolve_config(config: &Config, env: &dyn Fn(&str) -> Option) -> Reso ResolvedConfig { deck, env: env_file, + secrets, ontology, extensions, setup: config.setup.clone(), diff --git a/src/ontology/tests.rs b/src/ontology/tests.rs index fc3a9d7a..17d22f89 100644 --- a/src/ontology/tests.rs +++ b/src/ontology/tests.rs @@ -167,6 +167,56 @@ fn env_override_is_reflected_in_env_vars() { assert!(vars.contains(&("RUNE_DOMAIN".to_string(), "Systems".to_string()))); } +#[test] +fn secrets_manifest_env_beats_config_and_keeps_a_missing_path() { + let config = Config { + secrets: Some("~/configured/secretspec.toml".to_string()), + ..Config::default() + }; + let from_config = resolve_config(&config, &no_env); + let home = dirs::home_dir().expect("home for test"); + assert_eq!( + from_config.secrets_manifest(), + Some(home.join("configured/secretspec.toml")), + "a configured manifest is returned even when it does not exist" + ); + let from_env = resolve_config( + &config, + &env_from(&[("RUNE_SECRETS", "/from-env/secretspec.toml")]), + ); + let secrets = from_env.secrets.as_ref().expect("secrets from env"); + assert_eq!(secrets.source, Source::Env); + assert_eq!( + from_env.secrets_manifest(), + Some(PathBuf::from("/from-env/secretspec.toml")) + ); + assert!( + fields(&from_env) + .iter() + .any(|field| field.key == "secrets" && field.env == "RUNE_SECRETS") + ); +} + +#[test] +fn default_manifest_applies_only_when_present_and_no_env_file_is_named() { + let dir = tempfile::tempdir().expect("tempdir"); + let resolved = resolve_config(&Config::default(), &no_env); + assert_eq!(resolved.secrets_manifest_in(Some(dir.path())), None); + + let manifest = dir.path().join(SECRETS_MANIFEST); + std::fs::write(&manifest, "[project]\n").expect("manifest"); + assert_eq!( + resolved.secrets_manifest_in(Some(dir.path())), + Some(manifest.clone()) + ); + + let env_named = resolve_config(&Config::default(), &env_from(&[("RUNE_ENV", "/work.env")])); + assert_eq!(env_named.secrets_manifest_in(Some(dir.path())), None); + + let switched_off = resolve_config(&Config::default(), &env_from(&[("RUNE_SECRETS", "")])); + assert_eq!(switched_off.secrets_manifest_in(Some(dir.path())), None); +} + #[test] fn tilde_expansion_resolves_under_home() { let home = dirs::home_dir().expect("home for test");