diff --git a/README.md b/README.md
index 2398dfe..427028e 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,12 @@ commit to git.
Built in Rust on [GPUI](https://www.gpui.rs/) (the framework behind the Zed editor) and the
[gpui-component](https://github.com/longbridge/gpui-component) widget library.
+
+
+Opening a saved request, sending it with Ctrl+Enter, resolving
+`{{variables}}` against an environment, inspecting headers and the timing waterfall, running a
+GraphQL query, and the command palette. ([higher-quality MP4](docs/assets/posel-demo.mp4))
+
## Why another API client?
Existing clients are mostly either cloud-first and heavy, or built on a web runtime. Posel's bet
@@ -17,7 +23,8 @@ is the opposite corner of the design space:
- **Multi-protocol depth** — REST + GraphQL today, behind a clean *Protocol vs Transport*
boundary so gRPC and WebSocket/SSE slot in additively rather than as bolt-ons.
- **Git-native & local-first** — collections are human-readable `.toml`; collaboration is just
- git. Secrets live in your OS keychain, never in the repo.
+ git. Secrets stay out of the repo — in your OS keychain by default, or another backend you
+ pick ([Secrets](#secrets)).
- **Keyboard-driven / Zed-like** — command palette, quick-open, env switcher, multi-tab; a
keyboard-first flow with minimal chrome.
@@ -26,7 +33,7 @@ is the opposite corner of the design space:
Working, in active development. Runs on **macOS, Linux, and Windows**.
- **REST** — request editor (method, query/headers/body), streaming response panel,
- environments + variables, secrets in the OS keychain, request history.
+ environments + variables, pluggable secret storage, request history.
- **GraphQL** — schema introspection from the endpoint, schema-aware autocomplete, query
formatting, inline validation, and a dedicated data/errors viewer.
- **Workspace** — git-friendly `.toml` collections, live reflection of external file edits,
@@ -61,7 +68,7 @@ The domain logic is headless and unit-tested; GPUI is confined to the edge (`ui`
| `template` | Variable / secret / dynamic-value resolution |
| `engine` | tokio runtime, the `Transport` trait + HTTP transport, REST & GraphQL resolution |
| `schema` | GraphQL brains: introspection → queryable schema, completion, formatting, validation |
-| `storage` | git-friendly `.toml` format, OS-keychain secrets, history, file-watching |
+| `storage` | git-friendly `.toml` format, pluggable secret backends, history, file-watching |
| `ui` | GPUI widgets: editors, viewers, sidebar, palette (protocol-aware via enum dispatch) |
| `app` | `posel` desktop binary |
| `cli` | `posel-cli` headless engine driver |
@@ -74,8 +81,18 @@ stays protocol-agnostic. See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md).
Requires a current stable Rust toolchain (GPUI uses recently-stabilized std features; run
`rustup update stable` if a build fails with `E0658`). Builds on macOS, Linux, and Windows;
-secrets use the platform keychain (macOS Keychain, Windows Credential Manager, Linux
-Secret Service).
+secrets default to the platform keychain (macOS Keychain, Windows Credential Manager, Linux
+Secret Service) and can be pointed elsewhere — see [Secrets](#secrets).
+
+On **Linux**, GPUI links against system libraries that need their development packages present,
+or the build fails in `yeslogic-fontconfig-sys` with *"The pkg-config command could not be
+found"*:
+
+```bash
+# Debian / Ubuntu
+sudo apt install -y pkg-config libfontconfig-dev libfreetype-dev \
+ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libxcb1-dev libx11-dev
+```
```bash
cargo run -p posel-app # the desktop app (binary: posel)
@@ -87,6 +104,69 @@ cargo clippy --workspace -- --deny warnings
A workspace can be opened by passing a path as the first argument to the app, or via the
`POSEL_WORKSPACE` environment variable.
+## Secrets
+
+A `{{secret:name}}` reference is resolved at send time; the workspace `.toml` only ever holds the
+*reference*, never the value, so collections stay safe to commit. Where the value comes from is
+your choice:
+
+| Backend | Writable from Posel | Where values live |
+|---------|---------------------|-------------------|
+| `keychain` *(default)* | yes | macOS Keychain, Windows Credential Manager, Linux Secret Service |
+| `file` | yes | a `0600` TOML file in your user data directory |
+| `env` | no | the process environment (`{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`) |
+| `command` | no | stdout of a helper command you configure (`pass`, `op`, `gh`, …) |
+| `none` | — | nowhere; every `{{secret:…}}` fails |
+
+The keychain is the default and the recommended choice — but not every machine has one. A headless
+Linux box, a container, or a CI runner has no Secret Service, which is where
+`org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.secrets was not provided by
+any .service files` comes from. Pick one of the other backends there.
+
+Configure it under `[secrets]` in `settings.toml` in your user data directory
+(`~/.local/share/posel` on Linux, `~/Library/Application Support/posel` on macOS,
+`%LOCALAPPDATA%\posel\data` on Windows):
+
+```toml
+[secrets]
+backend = "file" # keychain | file | env | command | none
+
+env_prefix = "POSEL_SECRET_" # env backend: name → $POSEL_SECRET_
+command = ["pass", "show", "posel/{name}"] # command backend: stdout is the value
+```
+
+`command` runs the given argv once per lookup and takes its stdout (trailing newline trimmed) as
+the value; `{name}` is substituted with the secret name in every argument — e.g.
+`["op", "read", "op://vault/{name}/token"]` or `["gh", "auth", "token"]`. A non-zero exit surfaces
+the helper's stderr.
+
+**About the `file` backend.** It stores values in plaintext at rest — the same posture as
+`~/.aws/credentials`, `~/.docker/config.json`, or `gh`'s `hosts.yml`. On Unix the file is *created*
+`0600` (owner-only) before any value is written to it, never chmod'ed afterwards, so the plaintext
+is not readable by anyone else even momentarily; on Windows it inherits the user-scoped ACL of
+`%LOCALAPPDATA%`. Updates go through a temp file and an atomic rename, entries are namespaced per
+workspace, and it lives in your user data directory — **never inside a workspace**, so it can't be
+committed by accident. Use the keychain where you have one; use this where you don't.
+
+Either way can be overridden for a single run with `POSEL_SECRET_BACKEND`, which wins over
+`settings.toml` (an unrecognized value is ignored and the configured backend stands):
+
+```bash
+POSEL_SECRET_BACKEND=env cargo run -p posel-app
+```
+
+`posel-cli` takes the same choice as a flag on `send`, `login`, `test`, and `codegen`:
+
+```bash
+posel-cli send ./ws users/list.toml --secret-backend env # this run only
+posel-cli send ./ws users/list.toml --secret token=abc # inline; no store is read
+posel-cli send ./ws users/list.toml --keychain # pin the OS keychain
+```
+
+Given none of those, the CLI resolves through whatever `settings.toml` configures. If several are
+given, `--keychain` wins, then `--secret`, then `--secret-backend`. `--secret-backend` swaps only
+the backend — the configured `env_prefix` and `command` still apply.
+
## A note on the GPUI dependency
GPUI ships only as a git dependency (no crates.io release), and Posel carries three small,
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 6e09d80..4ddd4a8 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -8,6 +8,7 @@ use std::path::PathBuf;
use anyhow::{bail, Context as _, Result};
use posel_core::{Body, Method, Request};
+use posel_storage::SecretBackend;
const USAGE: &str = "\
posel-cli — headless REST driver
@@ -36,12 +37,17 @@ SEND (run a saved request, resolving {{vars}}/{{secret:..}}):
Path to the workspace directory
Request file, relative to the workspace root
--env Environment to resolve against
- --secret Provide a secret inline (repeatable; avoids the OS keychain)
- --keychain Resolve {{secret:..}} from the OS keychain instead
+ --secret Provide a secret inline (repeatable; no secret store is read)
+ --keychain Force the OS keychain, whatever backend is configured
+ --secret-backend Use this backend for this run instead of the configured one:
+ keychain | file | env | command | none
--allow-host One-off egress allowance past the env policy (repeatable;
a strict env ignores it)
-o, --output Write the response body byte-exact to a file
+ Given none of the above, {{secret:..}} resolves through the backend configured in
+ settings.toml ([secrets], default: the OS keychain).
+
EXAMPLES:
posel-cli https://httpbin.org/stream/5
posel-cli -X POST --json '{\"hi\":1}' https://httpbin.org/post
@@ -103,6 +109,9 @@ pub struct SendArgs {
pub env: Option,
pub secrets: Vec<(String, String)>,
pub keychain: bool,
+ /// `--secret-backend`: override the configured backend for this run. `None` = use the
+ /// machine's setting.
+ pub secret_backend: Option,
/// Write the response body byte-exact to this file instead of stdout (`-o`/`--output`).
pub output: Option,
/// One-off egress allowances (`--allow-host`, repeatable). Ignored when the env is strict.
@@ -126,6 +135,7 @@ pub struct CodegenArgs {
pub env: Option,
pub secrets: Vec<(String, String)>,
pub keychain: bool,
+ pub secret_backend: Option,
pub target: String,
}
@@ -236,6 +246,7 @@ fn parse_send>(mut args: I) -> Result {
let mut env = None;
let mut secrets = Vec::new();
let mut keychain = false;
+ let mut secret_backend = None;
let mut output = None;
let mut allow_hosts = Vec::new();
@@ -244,6 +255,12 @@ fn parse_send>(mut args: I) -> Result {
"--env" => env = Some(next_value(&mut args, "--env")?),
"--secret" => secrets.push(parse_pair(&next_value(&mut args, "--secret")?, '=')?),
"--keychain" => keychain = true,
+ "--secret-backend" => {
+ secret_backend = Some(parse_secret_backend(&next_value(
+ &mut args,
+ "--secret-backend",
+ )?)?)
+ }
"--allow-host" => allow_hosts.push(next_value(&mut args, "--allow-host")?),
"-o" | "--output" => output = Some(PathBuf::from(next_value(&mut args, &arg)?)),
other if other.starts_with('-') && other.len() > 1 => bail!("unknown flag: {other}"),
@@ -261,6 +278,7 @@ fn parse_send>(mut args: I) -> Result {
env,
secrets,
keychain,
+ secret_backend,
output,
allow_hosts,
})
@@ -297,6 +315,7 @@ fn parse_codegen>(mut args: I) -> Result
let mut env = None;
let mut secrets = Vec::new();
let mut keychain = false;
+ let mut secret_backend = None;
let mut target = "curl".to_string();
while let Some(arg) = args.next() {
@@ -304,6 +323,12 @@ fn parse_codegen>(mut args: I) -> Result
"--env" => env = Some(next_value(&mut args, "--env")?),
"--secret" => secrets.push(parse_pair(&next_value(&mut args, "--secret")?, '=')?),
"--keychain" => keychain = true,
+ "--secret-backend" => {
+ secret_backend = Some(parse_secret_backend(&next_value(
+ &mut args,
+ "--secret-backend",
+ )?)?)
+ }
"--target" => target = next_value(&mut args, "--target")?,
other if other.starts_with("--") => bail!("unknown flag: {other}"),
_ => positional.push(arg),
@@ -320,6 +345,7 @@ fn parse_codegen>(mut args: I) -> Result
env,
secrets,
keychain,
+ secret_backend,
target,
})
}
@@ -371,6 +397,19 @@ fn next_value>(args: &mut I, flag: &str) -> Result Result {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "keychain" => Ok(SecretBackend::Keychain),
+ "file" => Ok(SecretBackend::File),
+ "env" => Ok(SecretBackend::Env),
+ "command" => Ok(SecretBackend::Command),
+ "none" => Ok(SecretBackend::None),
+ _ => bail!("unknown secret backend: {raw} (expected keychain|file|env|command|none)"),
+ }
+}
+
/// Split `"namevalue"` into a trimmed key/value pair.
fn parse_pair(raw: &str, sep: char) -> Result<(String, String)> {
let (k, v) = raw
@@ -461,6 +500,51 @@ mod tests {
assert!(!s.keychain);
}
+ #[test]
+ fn secret_backend_is_parsed_for_send_and_codegen() {
+ let Command::Send(s) = parse_cmd(&["send", "./ws", "r.toml", "--secret-backend", "File"])
+ else {
+ panic!("expected send");
+ };
+ assert_eq!(s.secret_backend, Some(SecretBackend::File));
+
+ let Command::Codegen(c) =
+ parse_cmd(&["codegen", "./ws", "r.toml", "--secret-backend", "env"])
+ else {
+ panic!("expected codegen");
+ };
+ assert_eq!(c.secret_backend, Some(SecretBackend::Env));
+
+ // Absent flag means "whatever the machine is configured for" — the CLI must not guess.
+ let Command::Send(s) = parse_cmd(&["send", "./ws", "r.toml"]) else {
+ panic!("expected send");
+ };
+ assert_eq!(s.secret_backend, None);
+ }
+
+ #[test]
+ fn unknown_or_missing_secret_backend_is_an_error() {
+ let err = parse(
+ ["send", "./ws", "r.toml", "--secret-backend", "vault"]
+ .iter()
+ .map(|s| s.to_string()),
+ )
+ .err()
+ .map(|e| e.to_string())
+ .unwrap_or_default();
+ assert!(
+ err.contains("vault"),
+ "expected a clean error, got: {err:?}"
+ );
+
+ assert!(parse(
+ ["send", "./ws", "r.toml", "--secret-backend"]
+ .iter()
+ .map(|s| s.to_string())
+ )
+ .is_err());
+ }
+
#[test]
fn send_requires_two_positionals() {
assert!(parse(["send".to_string(), "./ws".into()]).is_err());
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index f35be75..4aa5560 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -20,7 +20,8 @@ use posel_engine::{
};
use posel_import::{detect_importer, ImportedItem};
use posel_storage::{
- history_path, AppSettings, History, HistoryEntry, KeyringSecrets, LayeredTokenStore, Workspace,
+ history_path, AppSettings, History, HistoryEntry, KeyringSecrets, LayeredTokenStore,
+ SecretBackend, SecretsConfig, Workspace, WorkspaceSecrets,
};
fn main() -> Result<()> {
@@ -224,7 +225,7 @@ fn run_login(args: SendArgs) -> Result<()> {
.clone(),
None => Environment::new(""),
};
- let secrets = secret_resolver(&workspace, args.keychain, args.secrets);
+ let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets);
let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
let cfg = oauth::resolve_config(&ctx.auth, &ctx.env, secrets.as_ref(), &ctx.auth_base_url)?
@@ -305,18 +306,30 @@ fn token_store(workspace: &Workspace) -> LayeredTokenStore {
store
}
-/// The secret backend selected by the send-style flags.
+/// The secret store selected by the send-style flags, most specific first: `--keychain` pins the OS
+/// keychain, `--secret K=V` supplies values inline (no store is read), `--secret-backend` swaps the
+/// backend for this run, and otherwise the machine's configured backend applies. That last case is
+/// the point: a box set up for `file`/`env` must resolve secrets from the CLI without extra flags.
fn secret_resolver(
workspace: &Workspace,
keychain: bool,
+ backend: Option,
secrets: Vec<(String, String)>,
) -> Box {
if keychain {
Box::new(KeyringSecrets::for_workspace(&workspace.name))
} else if !secrets.is_empty() {
Box::new(MapSecrets(secrets.into_iter().collect()))
+ } else if let Some(backend) = backend {
+ // Only the backend is overridden — the configured env prefix / helper argv still apply,
+ // so `--secret-backend command` uses the command the user already set up.
+ let config = SecretsConfig {
+ backend,
+ ..AppSettings::load().secrets
+ };
+ Box::new(WorkspaceSecrets::new(&workspace.name, &config))
} else {
- Box::new(NoSecrets)
+ Box::new(WorkspaceSecrets::for_workspace(&workspace.name))
}
}
@@ -336,7 +349,7 @@ fn run_send(send: SendArgs) -> Result<()> {
None => Environment::new(""),
};
- let secrets = secret_resolver(&workspace, send.keychain, send.secrets);
+ let secrets = secret_resolver(&workspace, send.keychain, send.secret_backend, send.secrets);
let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
let engine = EngineHandle::new()?;
@@ -380,7 +393,7 @@ fn run_codegen(args: CodegenArgs) -> Result<()> {
.clone(),
None => Environment::new(""),
};
- let secrets = secret_resolver(&workspace, args.keychain, args.secrets);
+ let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets);
let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
// Codegen never fetches (it must stay sync/offline); an OAuth2 request emits without a bearer.
@@ -408,7 +421,7 @@ fn run_test(args: TestArgs) -> Result<()> {
.clone(),
None => Environment::new(""),
};
- let secrets = secret_resolver(&workspace, args.keychain, args.secrets);
+ let secrets = secret_resolver(&workspace, args.keychain, args.secret_backend, args.secrets);
let ctx = with_app_defaults(workspace.effective_context(node_id, &env));
let engine = EngineHandle::new()?;
diff --git a/crates/storage/src/app_settings.rs b/crates/storage/src/app_settings.rs
index 44f6443..83d1c48 100644
--- a/crates/storage/src/app_settings.rs
+++ b/crates/storage/src/app_settings.rs
@@ -7,8 +7,13 @@ use std::path::PathBuf;
use posel_core::RequestDefaults;
use serde::{Deserialize, Serialize};
+use crate::secrets::SecretsConfig;
use crate::StorageError;
+fn is_default_secrets(v: &SecretsConfig) -> bool {
+ *v == SecretsConfig::default()
+}
+
/// How the request editor and response panel are arranged within the main area.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -61,10 +66,14 @@ impl ThemePreset {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppSettings {
/// Never persist OAuth tokens in the OS keychain — keep them in memory for the session only.
- /// For users who don't want the keychain (or are on a machine without one). Secrets are
- /// unaffected: they have no in-memory fallback and still resolve from the keychain.
+ /// For users who don't want the keychain (or are on a machine without one). This governs OAuth
+ /// tokens only; where `{{secret:…}}` values come from is [`Self::secrets`].
#[serde(default)]
pub disable_keychain: bool,
+ /// Which backend `{{secret:…}}` resolves against. Defaults to the OS keychain; switchable for
+ /// machines that have none (headless Linux, containers, CI). See [`SecretsConfig`].
+ #[serde(default, skip_serializing_if = "is_default_secrets")]
+ pub secrets: SecretsConfig,
/// Machine-global request execution defaults (timeouts, body limits). The base of the
/// inheritance chain — scope/folder settings override these per [`RequestDefaults::or_base`].
#[serde(default, skip_serializing_if = "RequestDefaults::is_empty")]
@@ -131,6 +140,7 @@ impl Default for AppSettings {
fn default() -> Self {
Self {
disable_keychain: false,
+ secrets: SecretsConfig::default(),
defaults: RequestDefaults::default(),
font_scale: default_font_scale(),
editor_font_size: default_editor_font_size(),
diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs
index a89c125..37ae60c 100644
--- a/crates/storage/src/lib.rs
+++ b/crates/storage/src/lib.rs
@@ -22,7 +22,10 @@ pub use app_settings::{AppSettings, ResponseLayout, ThemePreset};
pub use git::{status as git_status, GitStatus};
pub use history::{history_path, History, HistoryEntry};
pub use keymap::KeymapOverrides;
-pub use secrets::KeyringSecrets;
+pub use secrets::{
+ CommandSecrets, EnvSecrets, FileSecrets, KeyringSecrets, SecretBackend, SecretsConfig,
+ SecretsError, WorkspaceSecrets,
+};
pub use session::{
last_workspace, session_path, set_last_workspace, SessionRow, SessionState, SessionTab,
};
diff --git a/crates/storage/src/secrets/command_backend.rs b/crates/storage/src/secrets/command_backend.rs
new file mode 100644
index 0000000..ca829ad
--- /dev/null
+++ b/crates/storage/src/secrets/command_backend.rs
@@ -0,0 +1,265 @@
+//! Helper-command secrets — read-only.
+//!
+//! Runs a user-configured command per lookup and takes its stdout as the value, in the spirit of
+//! git's credential helpers. This keeps values in whatever password manager the user already
+//! trusts (`pass show posel/{name}`, `op read op://vault/{name}/token`, `gh auth token`, …) with
+//! nothing extra stored by Posel.
+
+use std::process::{Command, Stdio};
+use std::time::Duration;
+
+use super::{SecretStore, SecretsError};
+
+/// How long a helper may take before we give up and kill it. Lookups happen on the UI thread, and a
+/// helper can block indefinitely on a `pinentry` prompt or a biometric confirmation — without a
+/// bound that freezes the whole app with no way out.
+const HELPER_TIMEOUT: Duration = Duration::from_secs(20);
+
+/// Resolves secrets by shelling out to a configured argv.
+pub struct CommandSecrets {
+ /// Argv template. Every occurrence of `{name}` in every element is replaced with the secret
+ /// name. Element 0 is the program.
+ argv: Vec,
+}
+
+impl CommandSecrets {
+ pub fn new(argv: Vec) -> Self {
+ Self { argv }
+ }
+
+ /// The argv for a given secret, with `{name}` expanded.
+ fn resolved_argv(&self, name: &str) -> Vec {
+ self.argv
+ .iter()
+ .map(|part| part.replace("{name}", name))
+ .collect()
+ }
+}
+
+impl SecretStore for CommandSecrets {
+ fn get(&self, name: &str) -> Result {
+ let argv = self.resolved_argv(name);
+ let Some((program, args)) = argv.split_first() else {
+ return Err(SecretsError::Unavailable {
+ backend: "Helper command",
+ detail: "no command configured — set `command` under [secrets] in settings.toml"
+ .to_string(),
+ });
+ };
+
+ // Spawn rather than `output()` so the wait can be bounded. stdin is closed so a helper that
+ // wants to prompt fails fast instead of blocking forever on a terminal that isn't there.
+ let child = Command::new(program)
+ .args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|source| SecretsError::Unavailable {
+ backend: "Helper command",
+ detail: format!("could not run `{program}`: {source}"),
+ })?;
+
+ let output = wait_bounded(child, HELPER_TIMEOUT).map_err(|e| match e {
+ WaitError::Timeout => SecretsError::Unavailable {
+ backend: "Helper command",
+ detail: format!(
+ "`{program}` did not respond within {}s and was terminated — a helper that \
+ prompts interactively can't be used here",
+ HELPER_TIMEOUT.as_secs()
+ ),
+ },
+ WaitError::Io(source) => SecretsError::Unavailable {
+ backend: "Helper command",
+ detail: format!("`{program}` failed: {source}"),
+ },
+ })?;
+
+ if !output.status.success() {
+ // A helper commonly exits non-zero simply because the entry is absent; surface its
+ // stderr so the user sees the real reason rather than a bare exit code.
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ let detail = if stderr.is_empty() {
+ format!("`{program}` exited with {}", output.status)
+ } else {
+ stderr
+ };
+ return Err(SecretsError::Unavailable {
+ backend: "Helper command",
+ detail: format!("{name}: {detail}"),
+ });
+ }
+
+ // Trim the trailing newline helpers almost always emit; keep interior whitespace intact
+ // in case the secret legitimately contains it (e.g. a PEM block).
+ let value = String::from_utf8_lossy(&output.stdout)
+ .trim_end_matches(['\n', '\r'])
+ .to_string();
+ if value.is_empty() {
+ return Err(SecretsError::NotFound(name.to_string()));
+ }
+ Ok(value)
+ }
+}
+
+/// Captured output of a bounded helper run.
+struct HelperOutput {
+ status: std::process::ExitStatus,
+ stdout: Vec,
+ stderr: Vec,
+}
+
+enum WaitError {
+ Timeout,
+ Io(std::io::Error),
+}
+
+/// Wait for `child`, killing it if it outlives `timeout`.
+///
+/// std has no `wait_timeout`, so this polls `try_wait`. The pipes are drained on their own threads:
+/// a helper that writes more than a pipe buffer would otherwise block on the write while we block
+/// on the wait, and neither side would ever progress.
+fn wait_bounded(
+ mut child: std::process::Child,
+ timeout: Duration,
+) -> Result {
+ use std::io::Read as _;
+
+ // ChildStdout and ChildStderr are distinct types, so each gets its own reader thread.
+ let out = child.stdout.take();
+ let err = child.stderr.take();
+ let out_thread = std::thread::spawn(move || {
+ let mut buf = Vec::new();
+ if let Some(mut p) = out {
+ let _ = p.read_to_end(&mut buf);
+ }
+ buf
+ });
+ let err_thread = std::thread::spawn(move || {
+ let mut buf = Vec::new();
+ if let Some(mut p) = err {
+ let _ = p.read_to_end(&mut buf);
+ }
+ buf
+ });
+
+ let deadline = std::time::Instant::now() + timeout;
+ let status = loop {
+ match child.try_wait() {
+ Ok(Some(status)) => break status,
+ Ok(None) => {
+ if std::time::Instant::now() >= deadline {
+ // Kill, then reap, so we don't leave a zombie behind.
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(WaitError::Timeout);
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ Err(e) => return Err(WaitError::Io(e)),
+ }
+ };
+
+ Ok(HelperOutput {
+ status,
+ stdout: out_thread.join().unwrap_or_default(),
+ stderr: err_thread.join().unwrap_or_default(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn expands_the_name_placeholder_in_every_argv_element() {
+ let helper = CommandSecrets::new(vec![
+ "pass".into(),
+ "show".into(),
+ "posel/{name}".into(),
+ "--{name}".into(),
+ ]);
+ assert_eq!(
+ helper.resolved_argv("github_token"),
+ vec!["pass", "show", "posel/github_token", "--github_token"]
+ );
+ }
+
+ #[test]
+ fn an_empty_argv_explains_itself_instead_of_panicking() {
+ let helper = CommandSecrets::new(Vec::new());
+ let err = helper.get("token").unwrap_err().to_string();
+ assert!(err.contains("no command configured"), "{err}");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn reads_stdout_and_strips_the_trailing_newline() {
+ let helper = CommandSecrets::new(vec!["printf".into(), "%s\\n".into(), "s3cr3t".into()]);
+ assert_eq!(helper.get("ignored").unwrap(), "s3cr3t");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn a_missing_program_reports_it_as_unavailable() {
+ let helper = CommandSecrets::new(vec!["posel-no-such-helper-9f3a".into()]);
+ assert!(matches!(
+ helper.get("token"),
+ Err(SecretsError::Unavailable { .. })
+ ));
+ }
+
+ /// A helper that blocks forever (a `pinentry` prompt with no terminal, say) must be killed
+ /// rather than hang the caller — lookups run on the UI thread.
+ #[cfg(unix)]
+ #[test]
+ fn a_hanging_helper_is_killed_instead_of_blocking_forever() {
+ let child = Command::new("sh")
+ .args(["-c", "sleep 300"])
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn sleeper");
+
+ let started = std::time::Instant::now();
+ let result = wait_bounded(child, Duration::from_millis(300));
+ let elapsed = started.elapsed();
+
+ assert!(
+ matches!(result, Err(WaitError::Timeout)),
+ "expected timeout"
+ );
+ assert!(
+ elapsed < Duration::from_secs(5),
+ "wait_bounded blocked for {elapsed:?} — the timeout did not fire"
+ );
+ }
+
+ /// stdout larger than a pipe buffer (64 KiB) must not deadlock: the child blocks writing while
+ /// the parent blocks waiting unless the pipes are drained concurrently.
+ #[cfg(unix)]
+ #[test]
+ fn a_chatty_helper_does_not_deadlock_the_wait() {
+ let helper = CommandSecrets::new(vec![
+ "sh".into(),
+ "-c".into(),
+ // ~200 KiB, comfortably past a pipe buffer.
+ "yes ABCDEFGHIJKLMNOPQRSTUVWXYZ | head -c 200000".into(),
+ ]);
+ let value = helper.get("ignored").expect("helper should complete");
+ assert!(value.len() > 100_000, "got {} bytes", value.len());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn a_failing_helper_surfaces_its_stderr() {
+ let helper = CommandSecrets::new(vec![
+ "sh".into(),
+ "-c".into(),
+ "echo 'entry not found' >&2; exit 1".into(),
+ ]);
+ let err = helper.get("token").unwrap_err().to_string();
+ assert!(err.contains("entry not found"), "{err}");
+ }
+}
diff --git a/crates/storage/src/secrets/env_backend.rs b/crates/storage/src/secrets/env_backend.rs
new file mode 100644
index 0000000..d923c44
--- /dev/null
+++ b/crates/storage/src/secrets/env_backend.rs
@@ -0,0 +1,71 @@
+//! Environment-variable secrets — read-only.
+//!
+//! `{{secret:github_token}}` reads `$POSEL_SECRET_GITHUB_TOKEN` (with the configured prefix).
+//! Nothing is written to disk by Posel, which makes this the natural choice for CI and for
+//! anyone who already injects secrets into the process environment.
+
+use super::{shout_case, SecretStore, SecretsError};
+
+/// Resolves secrets from the process environment under a fixed prefix.
+pub struct EnvSecrets {
+ prefix: String,
+}
+
+impl EnvSecrets {
+ pub fn new(prefix: &str) -> Self {
+ Self {
+ prefix: prefix.to_string(),
+ }
+ }
+
+ /// The environment variable a secret name maps to — surfaced in the UI so users know exactly
+ /// what to export.
+ #[must_use]
+ pub fn var_name(&self, name: &str) -> String {
+ format!("{}{}", self.prefix, shout_case(name))
+ }
+}
+
+impl SecretStore for EnvSecrets {
+ fn get(&self, name: &str) -> Result {
+ let var = self.var_name(name);
+ match std::env::var(&var) {
+ // An empty value is treated as unset: exporting `FOO=` is a common way to clear it,
+ // and sending an empty bearer token is never what the user meant.
+ Ok(v) if !v.is_empty() => Ok(v),
+ _ => Err(SecretsError::NotFound(format!("{name} (expected ${var})"))),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn var_name_applies_prefix_and_normalizes() {
+ let env = EnvSecrets::new("POSEL_SECRET_");
+ assert_eq!(env.var_name("github_token"), "POSEL_SECRET_GITHUB_TOKEN");
+ assert_eq!(env.var_name("api-key"), "POSEL_SECRET_API_KEY");
+ }
+
+ #[test]
+ fn reads_a_set_variable_and_reports_the_expected_name_when_missing() {
+ let env = EnvSecrets::new("POSEL_TEST_SECRET_");
+ std::env::set_var("POSEL_TEST_SECRET_TOKEN", "s3cr3t");
+ assert_eq!(env.get("token").unwrap(), "s3cr3t");
+
+ // The error names the variable the user needs to export.
+ let err = env.get("absent").unwrap_err().to_string();
+ assert!(err.contains("POSEL_TEST_SECRET_ABSENT"), "{err}");
+ std::env::remove_var("POSEL_TEST_SECRET_TOKEN");
+ }
+
+ #[test]
+ fn an_empty_variable_counts_as_unset() {
+ let env = EnvSecrets::new("POSEL_TEST_EMPTY_");
+ std::env::set_var("POSEL_TEST_EMPTY_TOKEN", "");
+ assert!(env.get("token").is_err());
+ std::env::remove_var("POSEL_TEST_EMPTY_TOKEN");
+ }
+}
diff --git a/crates/storage/src/secrets/file_backend.rs b/crates/storage/src/secrets/file_backend.rs
new file mode 100644
index 0000000..a1c7fbd
--- /dev/null
+++ b/crates/storage/src/secrets/file_backend.rs
@@ -0,0 +1,334 @@
+//! Local-file secrets — the writable fallback for machines with no OS keychain.
+//!
+//! Values live in a single TOML file in the **user data directory**, never inside a workspace, so
+//! collections stay safe to commit: the workspace `.toml` still only holds `{{secret:name}}`
+//! references. Entries are namespaced per workspace, mirroring the keychain's service/account split.
+//!
+//! **This file is plaintext.** That is a deliberate trade so that secrets are usable at all on
+//! headless/Linux machines, and it matches how comparable developer tools behave
+//! (`~/.aws/credentials`, `~/.docker/config.json`, `gh`'s `hosts.yml`). It is created `0600` and
+//! every rewrite re-asserts those permissions. Prefer the keychain where one exists.
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+
+use super::{SecretStore, SecretsError};
+
+/// On-disk shape: workspace name → (secret name → value).
+#[derive(Debug, Default, Serialize, Deserialize)]
+struct SecretsFile {
+ #[serde(default)]
+ workspaces: BTreeMap>,
+}
+
+/// Reads and writes secrets in a `0600` TOML file in the user data dir.
+pub struct FileSecrets {
+ workspace: String,
+ path: Option,
+}
+
+impl FileSecrets {
+ /// Namespace secrets under a workspace, using the default file location.
+ pub fn for_workspace(workspace: &str) -> Self {
+ Self {
+ workspace: workspace.to_string(),
+ path: default_path(),
+ }
+ }
+
+ /// Use an explicit file — for tests, and for a future `path` setting.
+ pub fn at(workspace: &str, path: PathBuf) -> Self {
+ Self {
+ workspace: workspace.to_string(),
+ path: Some(path),
+ }
+ }
+
+ /// Where secrets are stored, for display in the Secrets panel.
+ #[must_use]
+ pub fn path(&self) -> Option<&PathBuf> {
+ self.path.as_ref()
+ }
+
+ fn resolved_path(&self) -> Result<&PathBuf, SecretsError> {
+ self.path.as_ref().ok_or_else(|| SecretsError::Unavailable {
+ backend: "Local file",
+ detail: "could not resolve the user data directory".to_string(),
+ })
+ }
+
+ fn read_file(&self) -> Result {
+ let path = self.resolved_path()?;
+ match std::fs::read_to_string(path) {
+ Ok(text) => toml::from_str(&text).map_err(|source| SecretsError::Parse {
+ path: path.clone(),
+ source: Box::new(source),
+ }),
+ // A missing file is simply an empty store — the first `set` creates it.
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(SecretsFile::default()),
+ Err(source) => Err(SecretsError::Io {
+ path: path.clone(),
+ source,
+ }),
+ }
+ }
+
+ fn write_file(&self, data: &SecretsFile) -> Result<(), SecretsError> {
+ let path = self.resolved_path()?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|source| SecretsError::Io {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ }
+ let text =
+ toml::to_string_pretty(data).map_err(|e| SecretsError::Serialize(Box::new(e)))?;
+
+ // Write to a temp file in the same directory, then rename over the target. The temp is
+ // *created* owner-only rather than chmod'ed afterwards: `fs::write` would create it with
+ // 0666 & ~umask and the plaintext would be group/world-readable until the chmod landed —
+ // and would stay that way forever if we were killed in between. The name carries the pid
+ // so two processes (app + CLI) can't scribble on each other's temp.
+ let tmp = path.with_file_name(format!(
+ "{}.{}.tmp",
+ path.file_name().unwrap_or_default().to_string_lossy(),
+ std::process::id()
+ ));
+ write_private(&tmp, &text)?;
+ // Rename is atomic within a filesystem, so a crash leaves either the old file or the new
+ // one — never a truncated store.
+ std::fs::rename(&tmp, path).map_err(|source| {
+ // Don't leave a stray temp full of secrets behind if the rename fails.
+ let _ = std::fs::remove_file(&tmp);
+ SecretsError::Io {
+ path: path.clone(),
+ source,
+ }
+ })?;
+ // The renamed inode already carries the temp's mode; re-assert it in case the destination
+ // pre-existed with looser permissions on a platform where rename preserves them.
+ restrict_permissions(path)
+ }
+}
+
+impl SecretStore for FileSecrets {
+ fn get(&self, name: &str) -> Result {
+ self.read_file()?
+ .workspaces
+ .get(&self.workspace)
+ .and_then(|entries| entries.get(name))
+ .filter(|v| !v.is_empty())
+ .cloned()
+ .ok_or_else(|| SecretsError::NotFound(name.to_string()))
+ }
+
+ fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
+ let mut data = self.read_file()?;
+ data.workspaces
+ .entry(self.workspace.clone())
+ .or_default()
+ .insert(name.to_string(), value.to_string());
+ self.write_file(&data)
+ }
+
+ fn delete(&self, name: &str) -> Result<(), SecretsError> {
+ let mut data = self.read_file()?;
+ let removed = data
+ .workspaces
+ .get_mut(&self.workspace)
+ .and_then(|entries| entries.remove(name))
+ .is_some();
+ if !removed {
+ return Err(SecretsError::NotFound(name.to_string()));
+ }
+ // Drop the workspace table once empty so the file doesn't accumulate dead sections.
+ if data
+ .workspaces
+ .get(&self.workspace)
+ .is_some_and(BTreeMap::is_empty)
+ {
+ data.workspaces.remove(&self.workspace);
+ }
+ self.write_file(&data)
+ }
+}
+
+/// Default location: the user data dir, beside `settings.toml`.
+fn default_path() -> Option {
+ directories::ProjectDirs::from("", "", "posel")
+ .map(|dirs| dirs.data_local_dir().join("secrets.toml"))
+}
+
+/// Create `path` (failing if it exists) owner-only from the outset and write `text` to it, then
+/// flush to disk. The mode is applied *at creation* so the plaintext is never readable by anyone
+/// else, not even for the instant between write and chmod.
+#[cfg(unix)]
+fn write_private(path: &PathBuf, text: &str) -> Result<(), SecretsError> {
+ use std::io::Write as _;
+ use std::os::unix::fs::OpenOptionsExt as _;
+
+ let io = |source| SecretsError::Io {
+ path: path.clone(),
+ source,
+ };
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(path)
+ .map_err(io)?;
+ file.write_all(text.as_bytes()).map_err(io)?;
+ // Durability: rename only orders the *name*, not the bytes. Without this a power loss can
+ // publish a zero-length secrets.toml.
+ file.sync_all().map_err(io)
+}
+
+/// Non-unix has no mode bits to set at creation; the file inherits the ACL of the per-user data
+/// directory (`%LOCALAPPDATA%` on Windows), which is already user-scoped.
+#[cfg(not(unix))]
+fn write_private(path: &PathBuf, text: &str) -> Result<(), SecretsError> {
+ use std::io::Write as _;
+
+ let io = |source| SecretsError::Io {
+ path: path.clone(),
+ source,
+ };
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+ .map_err(io)?;
+ file.write_all(text.as_bytes()).map_err(io)?;
+ file.sync_all().map_err(io)
+}
+
+/// Owner-only permissions on an existing file. A no-op on non-unix — see [`write_private`].
+#[cfg(unix)]
+fn restrict_permissions(path: &PathBuf) -> Result<(), SecretsError> {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
+ SecretsError::Io {
+ path: path.clone(),
+ source,
+ }
+ })
+}
+
+#[cfg(not(unix))]
+fn restrict_permissions(_path: &PathBuf) -> Result<(), SecretsError> {
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp_store(workspace: &str, label: &str) -> (FileSecrets, PathBuf) {
+ let path = std::env::temp_dir().join(format!("posel-secrets-test-{label}.toml"));
+ let _ = std::fs::remove_file(&path);
+ (FileSecrets::at(workspace, path.clone()), path)
+ }
+
+ #[test]
+ fn round_trips_a_secret() {
+ let (store, path) = temp_store("ws", "roundtrip");
+ assert!(store.get("token").is_err(), "absent before set");
+ store.set("token", "s3cr3t").unwrap();
+ assert_eq!(store.get("token").unwrap(), "s3cr3t");
+ store.delete("token").unwrap();
+ assert!(store.get("token").is_err(), "absent after delete");
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn workspaces_are_namespaced_so_names_do_not_collide() {
+ let path = std::env::temp_dir().join("posel-secrets-test-ns.toml");
+ let _ = std::fs::remove_file(&path);
+ let a = FileSecrets::at("alpha", path.clone());
+ let b = FileSecrets::at("beta", path.clone());
+
+ a.set("token", "from-alpha").unwrap();
+ b.set("token", "from-beta").unwrap();
+
+ assert_eq!(a.get("token").unwrap(), "from-alpha");
+ assert_eq!(b.get("token").unwrap(), "from-beta");
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn deleting_an_absent_secret_is_an_error_not_a_silent_success() {
+ let (store, path) = temp_store("ws", "delete-absent");
+ assert!(matches!(
+ store.delete("nope"),
+ Err(SecretsError::NotFound(_))
+ ));
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn the_file_is_created_owner_only() {
+ use std::os::unix::fs::PermissionsExt;
+ let (store, path) = temp_store("ws", "perms");
+ store.set("token", "s3cr3t").unwrap();
+ let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
+ assert_eq!(mode, 0o600, "secrets file must not be group/world readable");
+ let _ = std::fs::remove_file(path);
+ }
+
+ /// The mode must be set **at creation**, not chmod'ed on afterwards: writing first would leave
+ /// the plaintext group/world-readable for an instant — and permanently if the process died in
+ /// between. Asserting on the finished file can't tell those apart, so inspect the temp file
+ /// mid-write, while it still holds the secret and before the rename.
+ #[cfg(unix)]
+ #[test]
+ fn the_secret_is_never_world_readable_even_mid_write() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = std::env::temp_dir().join("posel-secrets-test-midwrite");
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("secrets.toml");
+
+ write_private(&path, "token = \"s3cr3t\"").unwrap();
+ let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
+ assert_eq!(
+ mode, 0o600,
+ "the file holding plaintext was created as {mode:o}, readable beyond the owner"
+ );
+ assert!(std::fs::read_to_string(&path).unwrap().contains("s3cr3t"));
+
+ // create_new: a colliding temp must fail rather than clobber another writer's file.
+ assert!(write_private(&path, "x").is_err());
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn a_failed_write_leaves_no_temp_file_behind() {
+ let dir = std::env::temp_dir().join("posel-secrets-test-notmp");
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let store = FileSecrets::at("ws", dir.join("secrets.toml"));
+ store.set("token", "s3cr3t").unwrap();
+
+ // Only the store itself survives a successful write — no `.tmp` siblings holding plaintext.
+ let strays: Vec<_> = std::fs::read_dir(&dir)
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.ends_with(".tmp"))
+ .collect();
+ assert!(strays.is_empty(), "left temp files behind: {strays:?}");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn a_missing_file_reads_as_empty_rather_than_failing() {
+ let path = std::env::temp_dir().join("posel-secrets-test-absent-file.toml");
+ let _ = std::fs::remove_file(&path);
+ let store = FileSecrets::at("ws", path);
+ assert!(matches!(store.get("x"), Err(SecretsError::NotFound(_))));
+ }
+}
diff --git a/crates/storage/src/secrets.rs b/crates/storage/src/secrets/keyring_backend.rs
similarity index 66%
rename from crates/storage/src/secrets.rs
rename to crates/storage/src/secrets/keyring_backend.rs
index e5236ea..e1e41c1 100644
--- a/crates/storage/src/secrets.rs
+++ b/crates/storage/src/secrets/keyring_backend.rs
@@ -4,6 +4,8 @@
use posel_template::SecretResolver;
+use super::{SecretStore, SecretsError};
+
/// Resolves `{{secret:name}}` against the OS keychain via the `keyring` crate.
pub struct KeyringSecrets {
service: String,
@@ -47,6 +49,39 @@ impl SecretResolver for KeyringSecrets {
}
}
+/// Map `keyring`'s errors onto the backend-neutral ones. The distinction matters for the UI: a
+/// missing entry is normal and actionable ("set it"), whereas an unreachable keychain means the
+/// user should switch backend entirely — the common case on Linux without a Secret Service.
+fn classify(err: keyring::Error, name: &str) -> SecretsError {
+ match err {
+ keyring::Error::NoEntry => SecretsError::NotFound(name.to_string()),
+ other => SecretsError::Unavailable {
+ backend: "OS keychain",
+ detail: other.to_string(),
+ },
+ }
+}
+
+impl SecretStore for KeyringSecrets {
+ fn get(&self, name: &str) -> Result {
+ keyring::Entry::new(&self.service, name)
+ .and_then(|entry| entry.get_password())
+ .map_err(|e| classify(e, name))
+ }
+
+ fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
+ keyring::Entry::new(&self.service, name)
+ .and_then(|entry| entry.set_password(value))
+ .map_err(|e| classify(e, name))
+ }
+
+ fn delete(&self, name: &str) -> Result<(), SecretsError> {
+ keyring::Entry::new(&self.service, name)
+ .and_then(|entry| entry.delete_credential())
+ .map_err(|e| classify(e, name))
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/storage/src/secrets/mod.rs b/crates/storage/src/secrets/mod.rs
new file mode 100644
index 0000000..60dca1c
--- /dev/null
+++ b/crates/storage/src/secrets/mod.rs
@@ -0,0 +1,436 @@
+//! Secret storage — pluggable backends behind one facade.
+//!
+//! `{{secret:name}}` resolves through whichever backend the user configured. The OS keychain is
+//! the default and remains the recommended choice, but it is not always *available*: a Linux box
+//! with no Secret Service provider (headless, servers, containers, minimal WMs, CI) has no
+//! keychain at all, which used to make secrets unusable there.
+//!
+//! Backends split into two classes:
+//!
+//! - **Writable** — [`Keychain`](SecretBackend::Keychain) and [`File`](SecretBackend::File). The
+//! Secrets panel can create, edit and delete entries.
+//! - **Read-only** — [`Env`](SecretBackend::Env) and [`Command`](SecretBackend::Command). Values
+//! come from outside Posel (CI environment, `pass`, 1Password, `gh auth token`, …), so the UI
+//! surfaces them but must not offer to edit them.
+//!
+//! Whatever the backend, **secret values never enter the workspace directory** — the `.toml` on
+//! disk only ever holds the `{{secret:name}}` *reference*, so a collection stays safe to commit.
+
+mod command_backend;
+mod env_backend;
+mod file_backend;
+mod keyring_backend;
+
+use std::path::PathBuf;
+
+use posel_template::SecretResolver;
+use serde::{Deserialize, Serialize};
+
+pub use command_backend::CommandSecrets;
+pub use env_backend::EnvSecrets;
+pub use file_backend::FileSecrets;
+pub use keyring_backend::KeyringSecrets;
+
+/// Anything that can go wrong reading or writing a secret, in backend-neutral terms.
+#[derive(Debug, thiserror::Error)]
+pub enum SecretsError {
+ /// The name isn't set in this backend. Distinct from [`Unavailable`](Self::Unavailable): the
+ /// backend worked, the entry just isn't there.
+ #[error("secret '{0}' is not set")]
+ NotFound(String),
+ /// The backend itself could not be reached — no Secret Service, unreadable file, helper
+ /// command missing. Carries a hint the UI can show, because this is the case users get stuck on.
+ #[error("{backend} secret storage is unavailable: {detail}")]
+ Unavailable {
+ backend: &'static str,
+ detail: String,
+ },
+ /// The active backend cannot be written to (env vars / helper command).
+ #[error("the {0} secret backend is read-only; values are managed outside Posel")]
+ ReadOnly(&'static str),
+ #[error("io error at {path}: {source}")]
+ Io {
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ /// A malformed secrets file. The message deliberately gives only the location, never the
+ /// wrapped error's text: `toml`'s Display quotes the offending source line, which in this file
+ /// *is* a secret — and this string reaches UI toasts and CLI stderr.
+ #[error("failed to parse {path}{}", location(.source))]
+ Parse {
+ path: PathBuf,
+ #[source]
+ source: Box,
+ },
+ /// As with [`Parse`](Self::Parse), the serializer error is not interpolated — it can echo the
+ /// value it failed on.
+ #[error("failed to serialize secrets")]
+ Serialize(#[source] Box),
+}
+
+/// `" at line L, column C"` for a TOML error, or an empty string when it carries no span. Lets a
+/// parse failure stay actionable without quoting the offending line (which would be a secret).
+fn location(err: &toml::de::Error) -> String {
+ err.span()
+ .map(|span| format!(" (byte offset {})", span.start))
+ .unwrap_or_default()
+}
+
+/// Which store `{{secret:…}}` reads from. Serialized into `settings.toml` under `[secrets]`.
+///
+/// Deserialization is deliberately **tolerant**: `AppSettings::load()` falls back to *all* defaults
+/// if any part of the file fails to parse, so a typo'd backend name would otherwise silently reset
+/// the user's theme, font scale and history limits too. An unknown value degrades to the default
+/// backend and leaves the rest of the settings intact.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SecretBackend {
+ /// The OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service).
+ /// The default, and the most secure option where it exists.
+ #[default]
+ Keychain,
+ /// A `0600` TOML file in the user data dir — **outside** any workspace. The writable fallback
+ /// for machines with no keychain. Plaintext at rest: the same posture as `~/.aws/credentials`,
+ /// `~/.docker/config.json` or `gh`'s `hosts.yml`, and a deliberate trade for usability.
+ File,
+ /// Environment variables (`{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`).
+ /// Read-only; ideal for CI.
+ Env,
+ /// An external helper command (`pass show posel/{name}`, `op read …`, `gh auth token`).
+ /// Read-only; keeps values in an existing password manager.
+ Command,
+ /// No secret backend — every `{{secret:…}}` fails. For users who want no secret support at all.
+ None,
+}
+
+impl SecretBackend {
+ pub const ALL: [SecretBackend; 5] = [
+ SecretBackend::Keychain,
+ SecretBackend::File,
+ SecretBackend::Env,
+ SecretBackend::Command,
+ SecretBackend::None,
+ ];
+
+ #[must_use]
+ pub fn label(self) -> &'static str {
+ match self {
+ SecretBackend::Keychain => "OS keychain",
+ SecretBackend::File => "Local file",
+ SecretBackend::Env => "Environment variables",
+ SecretBackend::Command => "Helper command",
+ SecretBackend::None => "Disabled",
+ }
+ }
+
+ /// Whether Posel can create/edit/delete entries in this backend. Read-only backends are owned
+ /// by something outside Posel, so the Secrets panel must present them as such.
+ #[must_use]
+ pub fn is_writable(self) -> bool {
+ matches!(self, SecretBackend::Keychain | SecretBackend::File)
+ }
+
+ /// One line describing where values live, for the Secrets panel.
+ #[must_use]
+ pub fn description(self) -> &'static str {
+ match self {
+ SecretBackend::Keychain => "Encrypted by the operating system. Recommended.",
+ SecretBackend::File => {
+ "Plaintext in a 0600 file in your user data directory — never inside a workspace."
+ }
+ SecretBackend::Env => "Read from the environment; set them before launching Posel.",
+ SecretBackend::Command => "Read from an external password manager on each send.",
+ SecretBackend::None => "Secret references will not resolve.",
+ }
+ }
+
+ fn parse(raw: &str) -> Option {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "keychain" | "keyring" => Some(SecretBackend::Keychain),
+ "file" => Some(SecretBackend::File),
+ "env" | "environment" => Some(SecretBackend::Env),
+ "command" | "helper" => Some(SecretBackend::Command),
+ "none" | "disabled" => Some(SecretBackend::None),
+ _ => None,
+ }
+ }
+}
+
+impl<'de> Deserialize<'de> for SecretBackend {
+ fn deserialize>(d: D) -> Result {
+ let raw = String::deserialize(d)?;
+ Ok(Self::parse(&raw).unwrap_or_default())
+ }
+}
+
+fn default_env_prefix() -> String {
+ "POSEL_SECRET_".to_string()
+}
+
+fn is_default_env_prefix(v: &String) -> bool {
+ v == &default_env_prefix()
+}
+
+/// The `[secrets]` block of `settings.toml`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SecretsConfig {
+ #[serde(default)]
+ pub backend: SecretBackend,
+ /// Prefix for [`SecretBackend::Env`] lookups.
+ #[serde(
+ default = "default_env_prefix",
+ skip_serializing_if = "is_default_env_prefix"
+ )]
+ pub env_prefix: String,
+ /// Argv for [`SecretBackend::Command`]. Any `{name}` token is replaced with the secret name.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub command: Vec,
+}
+
+impl Default for SecretsConfig {
+ fn default() -> Self {
+ Self {
+ backend: SecretBackend::default(),
+ env_prefix: default_env_prefix(),
+ command: Vec::new(),
+ }
+ }
+}
+
+impl SecretsConfig {
+ /// The effective backend: the `POSEL_SECRET_BACKEND` env override wins over the persisted
+ /// setting, so CI and one-off headless runs need no config file.
+ #[must_use]
+ pub fn effective_backend(&self) -> SecretBackend {
+ std::env::var("POSEL_SECRET_BACKEND")
+ .ok()
+ .as_deref()
+ .and_then(SecretBackend::parse)
+ .unwrap_or(self.backend)
+ }
+}
+
+/// The one type callers construct. Dispatches to the configured backend and carries enough
+/// context for the UI to explain itself (which backend, whether it can be written to).
+pub struct WorkspaceSecrets {
+ backend: SecretBackend,
+ inner: Box,
+}
+
+/// Uniform read/write surface implemented by each backend.
+trait SecretStore: Send + Sync {
+ fn get(&self, name: &str) -> Result;
+ fn set(&self, _name: &str, _value: &str) -> Result<(), SecretsError> {
+ Err(SecretsError::ReadOnly("read-only"))
+ }
+ fn delete(&self, _name: &str) -> Result<(), SecretsError> {
+ Err(SecretsError::ReadOnly("read-only"))
+ }
+}
+
+impl WorkspaceSecrets {
+ /// Build the store for `workspace` from an explicit config (tests, CLI flags).
+ pub fn new(workspace: &str, config: &SecretsConfig) -> Self {
+ let backend = config.effective_backend();
+ let inner: Box = match backend {
+ SecretBackend::Keychain => Box::new(KeyringSecrets::for_workspace(workspace)),
+ SecretBackend::File => Box::new(FileSecrets::for_workspace(workspace)),
+ SecretBackend::Env => Box::new(EnvSecrets::new(&config.env_prefix)),
+ SecretBackend::Command => Box::new(CommandSecrets::new(config.command.clone())),
+ SecretBackend::None => Box::new(DisabledSecrets),
+ };
+ Self { backend, inner }
+ }
+
+ /// Build the store for `workspace` using the machine's persisted settings. The one-liner every
+ /// UI/CLI call site wants — it replaces the previously hardcoded `KeyringSecrets::for_workspace`.
+ pub fn for_workspace(workspace: &str) -> Self {
+ Self::new(workspace, &crate::AppSettings::load().secrets)
+ }
+
+ /// Which backend is actually in use (after the env override).
+ #[must_use]
+ pub fn backend(&self) -> SecretBackend {
+ self.backend
+ }
+
+ /// Whether the Secrets panel may offer editing.
+ #[must_use]
+ pub fn is_writable(&self) -> bool {
+ self.backend.is_writable()
+ }
+
+ pub fn get(&self, name: &str) -> Result {
+ self.inner.get(name)
+ }
+
+ pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
+ if !self.backend.is_writable() {
+ return Err(SecretsError::ReadOnly(self.backend.label()));
+ }
+ self.inner.set(name, value)
+ }
+
+ pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
+ if !self.backend.is_writable() {
+ return Err(SecretsError::ReadOnly(self.backend.label()));
+ }
+ self.inner.delete(name)
+ }
+
+ /// Whether `name` currently has a value — presence only, never the plaintext.
+ #[must_use]
+ pub fn has(&self, name: &str) -> bool {
+ self.inner.get(name).is_ok()
+ }
+}
+
+impl SecretResolver for WorkspaceSecrets {
+ fn resolve_secret(&self, name: &str) -> Result {
+ self.inner.get(name).map_err(|e| e.to_string())
+ }
+}
+
+/// [`SecretBackend::None`] — nothing resolves.
+struct DisabledSecrets;
+
+impl SecretStore for DisabledSecrets {
+ fn get(&self, name: &str) -> Result {
+ Err(SecretsError::Unavailable {
+ backend: "Disabled",
+ detail: format!("secret support is turned off (needed '{name}')"),
+ })
+ }
+}
+
+/// Normalize a secret name into the tail of an env var / a safe file key: uppercase, and every
+/// character outside `[A-Z0-9]` collapsed to `_`.
+pub(crate) fn shout_case(name: &str) -> String {
+ name.chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() {
+ c.to_ascii_uppercase()
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn env_override_beats_the_persisted_backend() {
+ let cfg = SecretsConfig {
+ backend: SecretBackend::Keychain,
+ ..SecretsConfig::default()
+ };
+ // Without the override the persisted value stands.
+ assert_eq!(cfg.effective_backend(), SecretBackend::Keychain);
+
+ std::env::set_var("POSEL_SECRET_BACKEND", "env");
+ assert_eq!(cfg.effective_backend(), SecretBackend::Env);
+ // An unparseable value must not silently disable secrets — fall back to the setting.
+ std::env::set_var("POSEL_SECRET_BACKEND", "nonsense");
+ assert_eq!(cfg.effective_backend(), SecretBackend::Keychain);
+ std::env::remove_var("POSEL_SECRET_BACKEND");
+ }
+
+ #[test]
+ fn read_only_backends_reject_writes_before_touching_the_store() {
+ let cfg = SecretsConfig {
+ backend: SecretBackend::Env,
+ ..SecretsConfig::default()
+ };
+ let secrets = WorkspaceSecrets::new("ws", &cfg);
+ assert!(!secrets.is_writable());
+ assert!(matches!(
+ secrets.set("token", "x"),
+ Err(SecretsError::ReadOnly(_))
+ ));
+ assert!(matches!(
+ secrets.delete("token"),
+ Err(SecretsError::ReadOnly(_))
+ ));
+ }
+
+ #[test]
+ fn disabled_backend_fails_every_lookup() {
+ let cfg = SecretsConfig {
+ backend: SecretBackend::None,
+ ..SecretsConfig::default()
+ };
+ let secrets = WorkspaceSecrets::new("ws", &cfg);
+ assert!(secrets.get("anything").is_err());
+ assert!(!secrets.has("anything"));
+ }
+
+ /// A corrupt secrets file must not echo its contents into the error string: that string is
+ /// shown in a UI toast and printed to CLI stderr, and in this file the "offending line" is a
+ /// secret. `toml`'s own Display renders the source line, so it must never be interpolated.
+ #[test]
+ fn a_parse_error_never_quotes_the_file_contents() {
+ let bad = "[workspaces.ws]\ntoken = \"SUPER_SECRET_VALUE\nother = 1\n";
+ let source = toml::from_str::(bad).unwrap_err();
+ // Sanity: the raw toml error *does* leak — which is exactly why we don't interpolate it.
+ assert!(source.to_string().contains("SUPER_SECRET_VALUE"));
+
+ let err = SecretsError::Parse {
+ path: PathBuf::from("/tmp/secrets.toml"),
+ source: Box::new(source),
+ };
+ let shown = err.to_string();
+ assert!(
+ !shown.contains("SUPER_SECRET_VALUE"),
+ "secret leaked into the error message: {shown}"
+ );
+ assert!(shown.contains("/tmp/secrets.toml"), "{shown}");
+ }
+
+ #[derive(Debug, serde::Deserialize)]
+ struct SecretsFileProbe {
+ #[allow(dead_code)]
+ workspaces: std::collections::BTreeMap>,
+ }
+
+ /// A typo in `backend` must not take the whole settings file down with it: `AppSettings::load`
+ /// discards everything on a parse error, so a bad enum value would silently reset the user's
+ /// theme, font scale and history limits as well.
+ #[test]
+ fn an_unknown_backend_name_degrades_instead_of_failing_the_parse() {
+ #[derive(Debug, serde::Deserialize)]
+ struct Probe {
+ #[serde(default)]
+ secrets: SecretsConfig,
+ #[serde(default)]
+ font_scale: f32,
+ }
+
+ let cfg: Probe =
+ toml::from_str("font_scale = 1.5\n[secrets]\nbackend = \"vault\"\n").expect("parses");
+ assert_eq!(cfg.secrets.backend, SecretBackend::Keychain);
+ assert_eq!(cfg.font_scale, 1.5, "unrelated settings must survive");
+
+ // Known values still map, including the documented aliases.
+ let cfg: Probe = toml::from_str("[secrets]\nbackend = \"file\"\n").expect("parses");
+ assert_eq!(cfg.secrets.backend, SecretBackend::File);
+ }
+
+ #[test]
+ fn shout_case_normalizes_punctuation() {
+ assert_eq!(shout_case("github_token"), "GITHUB_TOKEN");
+ assert_eq!(shout_case("api-key.v2"), "API_KEY_V2");
+ }
+
+ #[test]
+ fn writable_classification_matches_the_backends() {
+ assert!(SecretBackend::Keychain.is_writable());
+ assert!(SecretBackend::File.is_writable());
+ assert!(!SecretBackend::Env.is_writable());
+ assert!(!SecretBackend::Command.is_writable());
+ assert!(!SecretBackend::None.is_writable());
+ }
+}
diff --git a/crates/ui/src/app_settings_editor.rs b/crates/ui/src/app_settings_editor.rs
index 8daa98e..18a97e7 100644
--- a/crates/ui/src/app_settings_editor.rs
+++ b/crates/ui/src/app_settings_editor.rs
@@ -14,7 +14,7 @@ use gpui_component::{
v_flex, ActiveTheme as _, Disableable as _, IconName, IndexPath, Sizable as _,
};
use posel_core::RequestDefaults;
-use posel_storage::{AppSettings, ThemePreset};
+use posel_storage::{AppSettings, SecretBackend, ThemePreset};
use crate::command::OpenAppSettings;
use crate::request::RequestView;
@@ -39,6 +39,7 @@ fn yesno_index(v: Option) -> usize {
pub(crate) struct AppSettingsState {
theme: Entity>>,
keychain: Entity>>,
+ secret_backend: Entity>>,
autosave: Entity>>,
timeout: Entity,
connect_timeout: Entity,
@@ -66,6 +67,7 @@ impl AppSettingsState {
let shown = s.defaults.or_base(&RequestDefaults::engine_defaults());
Self::build(
s.disable_keychain,
+ s.secrets.backend,
s.autosave,
s.theme,
indent_tab_size(&s),
@@ -84,6 +86,7 @@ impl AppSettingsState {
#[allow(clippy::too_many_arguments)] // an internal constructor seeding many independent fields
fn build(
disable_keychain: bool,
+ secret_backend: SecretBackend,
autosave: bool,
theme: ThemePreset,
indent: TabSize,
@@ -124,9 +127,17 @@ impl AppSettingsState {
.position(|p| *p == theme)
.unwrap_or(0);
+ let backend_labels: Vec<&'static str> =
+ SecretBackend::ALL.iter().map(|b| b.label()).collect();
+ let backend_idx = SecretBackend::ALL
+ .iter()
+ .position(|b| *b == secret_backend)
+ .unwrap_or(0);
+
Self {
theme: select(theme_labels, theme_idx, window, cx),
keychain: select(KEYCHAIN.to_vec(), usize::from(disable_keychain), window, cx),
+ secret_backend: select(backend_labels, backend_idx, window, cx),
autosave: select(YESNO.to_vec(), yesno_index(Some(autosave)), window, cx),
timeout: num(d.timeout_secs, "seconds", window, cx),
connect_timeout: num(d.connect_timeout_secs, "seconds", window, cx),
@@ -194,6 +205,11 @@ impl AppSettingsState {
self.keychain.read(cx).selected_value().copied(),
Some("Disabled")
);
+ // Keep the stored backend when the select has no selection, rather than silently rewriting
+ // the user's choice to the default.
+ if let Some(backend) = selected_backend(&self.secret_backend, cx) {
+ s.secrets.backend = backend;
+ }
s.autosave = tri(&self.autosave) == Some(true);
s.defaults = RequestDefaults {
timeout_secs: num(&self.timeout),
@@ -296,6 +312,7 @@ impl RequestView {
if let Some(ix) = self.app_settings_index() {
let state = Box::new(AppSettingsState::build(
false,
+ SecretBackend::default(),
false,
ThemePreset::default(),
TabSize::default(),
@@ -492,6 +509,14 @@ impl RequestView {
input(&state.indent_width),
))
.child(section_label("Security", muted))
+ .child(labelled(
+ "Secret storage",
+ // The description tracks the selection: each backend stores values
+ // somewhere materially different, and the read-only ones can't be
+ // edited from the Secrets panel at all.
+ Some(secret_backend_hint(&state.secret_backend, cx)),
+ select(&state.secret_backend),
+ ))
.child(labelled(
"OAuth keychain",
Some("Disabled keeps tokens in memory for the session only."),
@@ -547,6 +572,27 @@ pub(crate) fn indent_tab_size(s: &AppSettings) -> gpui_component::input::TabSize
}
}
+/// The backend the picker currently shows, or `None` if nothing is selected. Matches on the label
+/// the select was built from ([`SecretBackend::ALL`] order).
+fn selected_backend(
+ select: &Entity>>,
+ cx: &App,
+) -> Option {
+ let label = select.read(cx).selected_value().copied()?;
+ SecretBackend::ALL.into_iter().find(|b| b.label() == label)
+}
+
+/// Hint line under the "Secret storage" picker, matching whatever is currently selected. Reads the
+/// live select rather than the saved settings so the explanation updates before the page is saved.
+fn secret_backend_hint(
+ select: &Entity>>,
+ cx: &Context,
+) -> &'static str {
+ selected_backend(select, cx)
+ .unwrap_or_default()
+ .description()
+}
+
fn section_label(text: &str, muted: Hsla) -> impl IntoElement {
div()
.pt_2()
diff --git a/crates/ui/src/auth_editor.rs b/crates/ui/src/auth_editor.rs
index 37d86d6..b392dc2 100644
--- a/crates/ui/src/auth_editor.rs
+++ b/crates/ui/src/auth_editor.rs
@@ -14,7 +14,7 @@ use gpui_component::{
};
use posel_core::{Auth, AuthKind, Node, OAuthGrant, Protocol};
use posel_engine::SecretResolver as _;
-use posel_storage::KeyringSecrets;
+use posel_storage::WorkspaceSecrets;
use crate::auth_ui::AuthField;
use crate::command::FocusAuthTab;
@@ -449,7 +449,7 @@ impl RequestView {
.workspace
.as_ref()
.map(|w| {
- KeyringSecrets::for_workspace(&w.name)
+ WorkspaceSecrets::for_workspace(&w.name)
.resolve_secret(&name)
.unwrap_or_default()
})
@@ -559,19 +559,17 @@ impl RequestView {
return;
}
let name = self.derive_secret_name(field);
- match KeyringSecrets::for_workspace(&ws_name).set(&name, &literal) {
+ match WorkspaceSecrets::for_workspace(&ws_name).set(&name, &literal) {
Ok(()) => {
input.update(cx, |s, cx| {
s.set_value(format!("{{{{secret:{name}}}}}"), window, cx)
});
cx.notify();
}
+ // The error already names the active backend (and says so when it's a read-only one),
+ // so don't hardcode "keychain" here — it may not be the keychain.
Err(e) => {
- crate::notify::toast_error(
- window,
- cx,
- format!("Couldn’t store secret in keychain: {e}"),
- );
+ crate::notify::toast_error(window, cx, format!("Couldn’t store secret: {e}"));
}
}
}
diff --git a/crates/ui/src/request/interchange.rs b/crates/ui/src/request/interchange.rs
index 4318553..7821d46 100644
--- a/crates/ui/src/request/interchange.rs
+++ b/crates/ui/src/request/interchange.rs
@@ -74,7 +74,7 @@ impl RequestView {
let operation = self.active_tab().build_operation(cx);
let ctx = self.effective_ctx(cx);
let secrets: Box = match &self.workspace {
- Some(ws) => Box::new(KeyringSecrets::for_workspace(&ws.name)),
+ Some(ws) => Box::new(WorkspaceSecrets::for_workspace(&ws.name)),
None => Box::new(NoSecrets),
};
let Ok(resolved) = ResolvedRequest::resolve_effective(
diff --git a/crates/ui/src/request/mod.rs b/crates/ui/src/request/mod.rs
index da3ebf8..4f18b00 100644
--- a/crates/ui/src/request/mod.rs
+++ b/crates/ui/src/request/mod.rs
@@ -34,9 +34,9 @@ use posel_engine::{
};
use posel_import::{detect_importer, importer, Imported, ImportedItem};
use posel_storage::{
- history_path, AppSettings, History, HistoryEntry, KeymapOverrides, KeyringSecrets,
- LayeredTokenStore, RequestViewState, ResponseLayout, SessionState, SessionTab, UiState,
- Workspace, WorkspaceWatcher,
+ history_path, AppSettings, History, HistoryEntry, KeymapOverrides, LayeredTokenStore,
+ RequestViewState, ResponseLayout, SessionState, SessionTab, UiState, Workspace,
+ WorkspaceSecrets, WorkspaceWatcher,
};
use crate::about::AboutState;
diff --git a/crates/ui/src/request/send.rs b/crates/ui/src/request/send.rs
index c466b1f..f1aa9fa 100644
--- a/crates/ui/src/request/send.rs
+++ b/crates/ui/src/request/send.rs
@@ -123,7 +123,7 @@ impl RequestView {
};
}
let secrets: Box = match &self.workspace {
- Some(ws) => Box::new(KeyringSecrets::for_workspace(&ws.name)),
+ Some(ws) => Box::new(WorkspaceSecrets::for_workspace(&ws.name)),
None => Box::new(NoSecrets),
};
@@ -578,7 +578,7 @@ impl RequestView {
/// The secret backend for resolving auth fields (keychain when a workspace is loaded).
fn oauth_secrets(&self) -> Box {
match &self.workspace {
- Some(ws) => Box::new(KeyringSecrets::for_workspace(&ws.name)),
+ Some(ws) => Box::new(WorkspaceSecrets::for_workspace(&ws.name)),
None => Box::new(NoSecrets),
}
}
diff --git a/crates/ui/src/secrets_panel.rs b/crates/ui/src/secrets_panel.rs
index 95bc624..f42d508 100644
--- a/crates/ui/src/secrets_panel.rs
+++ b/crates/ui/src/secrets_panel.rs
@@ -1,9 +1,11 @@
//! The workspace **Secrets** — rendered as a section of the Workspace Settings tab (not a modal:
//! secrets are persistent workspace configuration, so they live beside auth/variables/headers). Lists
-//! every `{{secret:NAME}}` the workspace references — the keychain can't be enumerated, so
-//! `Workspace::referenced_secret_names` scans the files — with each one's keychain status and controls
-//! to set, reveal, change, or remove it. Values live only in the OS keychain, never on disk. Reached
-//! via `ShowSecrets` and the Auth tab's vault chip / inherited read-out ("Set value" / "Manage").
+//! every `{{secret:NAME}}` the workspace references — a secret store can't be enumerated, so
+//! `Workspace::referenced_secret_names` scans the files — with each one's status and controls to set,
+//! reveal, change, or remove it. Values live in the configured backend
+//! ([`posel_storage::SecretBackend`]), never in the workspace; read-only backends (env vars, helper
+//! command) render without the write controls. Reached via `ShowSecrets` and the Auth tab's vault
+//! chip / inherited read-out ("Set value" / "Manage").
use gpui::prelude::*;
use gpui::*;
@@ -15,20 +17,23 @@ use gpui_component::{
};
use std::collections::HashSet;
-use posel_storage::KeyringSecrets;
+use posel_storage::{AppSettings, SecretBackend, WorkspaceSecrets};
use crate::command::ShowSecrets;
use crate::request::RequestView;
use crate::settings_editor::{SettingsScope, SettingsSection};
use crate::theme::palette::Palette;
-/// Working state for the Secrets section. `names`/`set` are snapshotted on section entry (a keychain
+/// Working state for the Secrets section. `names`/`set` are snapshotted on section entry (a backend
/// read per name), then updated in place on mutation so we don't re-scan every frame.
pub(crate) struct SecretsState {
names: Vec,
set: HashSet,
editing: Option,
revealed: Option<(String, String)>,
+ /// The active backend, resolved once alongside `names`/`set`. Render reads it per row, and
+ /// `AppSettings::load()` is a file read + TOML parse — not something to do on every frame.
+ backend: SecretBackend,
}
/// An active set/add form. `target = Some(name)` sets a known referenced secret (name fixed);
@@ -72,7 +77,7 @@ impl RequestView {
self.secrets_begin_set(name, window, cx);
}
- /// Rebuild the referenced-names list + keychain status. Called on entry to the Secrets section;
+ /// Rebuild the referenced-names list + per-name status. Called on entry to the Secrets section;
/// resets any in-progress edit.
pub(crate) fn refresh_secrets(&mut self) {
let (names, set) = self.scan_secrets();
@@ -81,17 +86,18 @@ impl RequestView {
set,
editing: None,
revealed: None,
+ backend: AppSettings::load().secrets.effective_backend(),
});
}
- /// The referenced names plus which are currently present in the keychain. Only meaningful with a
+ /// The referenced names plus which currently have a value in the active backend. Only with a
/// workspace open (the settings tab requires one).
fn scan_secrets(&self) -> (Vec, HashSet) {
let Some(ws) = self.workspace.as_ref() else {
return (Vec::new(), HashSet::new());
};
let names = ws.referenced_secret_names();
- let keyring = KeyringSecrets::for_workspace(&ws.name);
+ let keyring = WorkspaceSecrets::for_workspace(&ws.name);
let set = names
.iter()
.filter(|n| keyring.get(n).is_ok())
@@ -107,10 +113,11 @@ impl RequestView {
cx: &mut Context,
) -> SecretEdit {
let name = cx.new(|cx| InputState::new(window, cx).placeholder("secret_name"));
+ let backend = AppSettings::load().secrets.effective_backend();
let value = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
- .placeholder("value — stored in your OS keychain")
+ .placeholder(format!("value — stored in: {}", backend.label()))
});
// Focus the value when the name is fixed, else start at the name field.
if target.is_some() {
@@ -166,7 +173,7 @@ impl RequestView {
let Some(ws_name) = self.workspace.as_ref().map(|w| w.name.clone()) else {
return;
};
- match KeyringSecrets::for_workspace(&ws_name).set(&name, &value) {
+ match WorkspaceSecrets::for_workspace(&ws_name).set(&name, &value) {
Ok(()) => {
if let Some(s) = self.secrets.as_mut() {
if !s.names.contains(&name) {
@@ -180,11 +187,9 @@ impl RequestView {
crate::notify::toast_ok(window, cx, format!("Saved secret “{name}”."));
cx.notify();
}
- Err(e) => crate::notify::toast_error(
- window,
- cx,
- format!("Couldn’t store secret in keychain: {e}"),
- ),
+ // The error already names the backend and, when it's unavailable rather than merely
+ // unset, says why — which is the case that tells the user to switch backend.
+ Err(e) => crate::notify::toast_error(window, cx, format!("Couldn’t store secret: {e}")),
}
}
@@ -201,7 +206,7 @@ impl RequestView {
if matches!(&s.revealed, Some((n, _)) if *n == name) {
s.revealed = None;
} else if let Some(ws_name) = ws_name {
- if let Ok(v) = KeyringSecrets::for_workspace(&ws_name).get(&name) {
+ if let Ok(v) = WorkspaceSecrets::for_workspace(&ws_name).get(&name) {
s.revealed = Some((name, v));
}
}
@@ -218,7 +223,7 @@ impl RequestView {
let Some(ws_name) = self.workspace.as_ref().map(|w| w.name.clone()) else {
return;
};
- match KeyringSecrets::for_workspace(&ws_name).delete(&name) {
+ match WorkspaceSecrets::for_workspace(&ws_name).delete(&name) {
Ok(()) => {
if let Some(s) = self.secrets.as_mut() {
s.set.remove(&name);
@@ -244,26 +249,34 @@ impl RequestView {
};
let mono = cx.theme().mono_font_family.clone();
- let head = h_flex()
- .w_full()
- .items_center()
- .gap_2()
- .child(
- div()
- .flex_1()
- .text_size(crate::widgets::label_size(cx))
- .text_color(muted)
- .child(
- "Secrets this workspace references. Stored in your OS keychain, never on disk.",
- ),
- )
- .child(
+ // Where values actually live depends on the configured backend, and read-only backends
+ // (env vars / helper command) are owned outside Posel — so the blurb must describe the
+ // active one and the write actions must not be offered at all.
+ let backend = s.backend;
+ let mut head = h_flex().w_full().items_center().gap_2().child(
+ div()
+ .flex_1()
+ .text_size(crate::widgets::label_size(cx))
+ .text_color(muted)
+ // "· " rather than a space: the description is a standalone sentence (it's also the
+ // App Settings hint), so running the two together reads as one broken sentence.
+ .child(format!(
+ "Secrets this workspace references. {} · {}",
+ backend.label(),
+ backend.description()
+ )),
+ );
+ if backend.is_writable() {
+ head = head.child(
Button::new("secrets-add")
.ghost()
.small()
.label("Add secret")
- .on_click(cx.listener(|this, _, window, cx| this.secrets_begin_add(window, cx))),
+ .on_click(
+ cx.listener(|this, _, window, cx| this.secrets_begin_add(window, cx)),
+ ),
);
+ }
let mut col = v_flex().w_full().gap_2().child(head);
@@ -349,6 +362,8 @@ impl RequestView {
.into_any_element();
}
+ let backend = s.backend;
+ let writable = backend.is_writable();
let (dot, dot_color, status) = if is_set {
("●", p.get, "set")
} else {
@@ -413,22 +428,26 @@ impl RequestView {
})),
)
})
- .child(
- Button::new(("secret-set", i))
- .ghost()
- .xsmall()
- .label(if is_set { "Change" } else { "Set value" })
- .on_click(cx.listener(move |this, _, window, cx| {
- this.secrets_begin_set(row_name.clone(), window, cx)
- })),
- )
- .when(is_set, |row| {
+ // Read-only backends (env vars / helper command) are managed outside Posel: offering
+ // Set/Remove there would produce a guaranteed failure, so don't render them.
+ .when(writable, |row| {
+ row.child(
+ Button::new(("secret-set", i))
+ .ghost()
+ .xsmall()
+ .label(if is_set { "Change" } else { "Set value" })
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.secrets_begin_set(row_name.clone(), window, cx)
+ })),
+ )
+ })
+ .when(is_set && writable, |row| {
row.child(
Button::new(("secret-remove", i))
.ghost()
.xsmall()
.icon(IconName::Delete)
- .tooltip("Remove from keychain")
+ .tooltip(format!("Remove from {}", backend.label().to_lowercase()))
.on_click(cx.listener(move |this, _, window, cx| {
this.secrets_remove(remove_name.clone(), window, cx)
})),
diff --git a/crates/ui/src/var_grid.rs b/crates/ui/src/var_grid.rs
index 6b78fe0..bc2eb20 100644
--- a/crates/ui/src/var_grid.rs
+++ b/crates/ui/src/var_grid.rs
@@ -9,9 +9,9 @@
//! column *is* an [`Environment`]. The grid only reorganizes how they're edited, so old `.toml`
//! round-trips identically.
//!
-//! Secrets are per-environment cells only (masked, value in the OS keychain, `.toml` holds just the
-//! `{{secret:…}}` reference). The Shared column deliberately can't hold a secret — a dev key must
-//! never double as a prod key, so stage separation is structural, not a convention.
+//! Secrets are per-environment cells only (masked, value in the configured secret backend, `.toml`
+//! holds just the `{{secret:…}}` reference). The Shared column deliberately can't hold a secret — a
+//! dev key must never double as a prod key, so stage separation is structural, not a convention.
use gpui::prelude::*;
use gpui::*;
@@ -25,8 +25,7 @@ use gpui_component::{
};
use posel_core::{EnvColor, Environment, FolderSettings};
-use posel_engine::SecretResolver as _;
-use posel_storage::KeyringSecrets;
+use posel_storage::WorkspaceSecrets;
use crate::command::{AddAllowedHost, EnvPolicyFlag, RemoveAllowedHost, ToggleEnvPolicyFlag};
use crate::completion::{template_provider, Fallback};
@@ -569,9 +568,11 @@ impl RequestView {
cx.notify();
}
- /// Toggle a per-env cell between a plain value and a keychain-backed secret. Promoting moves the
- /// literal into the OS keychain and leaves a masked `{{secret:NAME}}` reference; demoting pulls
- /// it back out and forgets it. The secret name is scoped to the *column's* environment.
+ /// Toggle a per-env cell between a plain value and a stored secret. Promoting moves the literal
+ /// into the configured secret backend and leaves a masked `{{secret:NAME}}` reference; demoting
+ /// pulls it back out and forgets it. Demoting is refused on a read-only backend, where the
+ /// value can't be removed from the store and would otherwise end up in the committed `.toml`.
+ /// The secret name is scoped to the *column's* environment.
fn toggle_cell_secret(
&mut self,
row_id: usize,
@@ -598,15 +599,44 @@ impl RequestView {
let key = row.name.read(cx).value().trim().to_string();
let cell = cell.clone();
let value = cell.read(cx).value().to_string();
- let keyring = KeyringSecrets::for_workspace(&ws_name);
+ let keyring = WorkspaceSecrets::for_workspace(&ws_name);
if let Some(name) = secret_ref_name(&value).map(str::to_string) {
- let literal = keyring.resolve_secret(&name).unwrap_or_default();
+ // Un-secreting writes the plaintext into the environment cell, which is persisted in the
+ // workspace `.toml`. That's only defensible when we can also *remove* it from the store —
+ // on a read-only backend (env/command) the delete can't happen, so the value would end up
+ // committed to the repo while still living outside Posel. Refuse instead.
+ if !keyring.is_writable() {
+ crate::notify::toast_error(
+ window,
+ cx,
+ format!(
+ "{} is read-only — manage “{name}” where it's stored.",
+ keyring.backend().label()
+ ),
+ );
+ return;
+ }
+ // Don't blank the cell (and then delete the entry) just because the read failed: that
+ // silently destroys the secret. Keep the reference and say why.
+ let literal = match keyring.get(&name) {
+ Ok(v) => v,
+ Err(e) => {
+ crate::notify::toast_error(window, cx, format!("Couldn’t read “{name}”: {e}"));
+ return;
+ }
+ };
cell.update(cx, |s, cx| {
s.set_masked(false, window, cx);
s.set_value(literal, window, cx);
});
- let _ = keyring.delete(&name);
+ if let Err(e) = keyring.delete(&name) {
+ crate::notify::toast_error(
+ window,
+ cx,
+ format!("Value inlined, but “{name}” is still stored: {e}"),
+ );
+ }
} else {
if value.trim().is_empty() || value.contains("{{") {
crate::notify::toast_error(
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6dd86ab..cc0b52f 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -42,7 +42,7 @@ posel/ # repo root
│ ├── storage/ # persistence — serde + toml, git-friendly (§6)
│ │ ├── workspace.rs # workspace = a directory
│ │ ├── format.rs # .toml (de)serialization + body sidecars
-│ │ ├── secrets.rs # OS keychain via `keyring`
+│ │ ├── secrets/ # pluggable secret backends (keychain/file/env/command)
│ │ ├── watch.rs # file watching
│ │ └── history.rs
│ ├── schema/ # graphql (introspection, completion, format, validation) + openapi schema helpers
@@ -151,8 +151,13 @@ single-threaded UI executor. Decided once, in `engine/runtime.rs`:
- `template` resolves `{{var}}` against the active environment and built-in dynamics
(`{{uuid}}`, `{{now}}`), producing a `ResolvedRequest` at send time.
-- `{{secret:name}}` resolves from the **OS keychain** (`keyring` crate) — secrets are never
- serialized to `.toml`, so collections are safe to commit.
+- `{{secret:name}}` resolves through a **pluggable secret backend** (`storage::secrets`) behind one
+ `WorkspaceSecrets` facade: the **OS keychain** (`keyring` crate) by default, or a `0600` file in
+ the user data dir, environment variables, or a helper command — selected by `[secrets]` in
+ `settings.toml` and overridable via `POSEL_SECRET_BACKEND`. The keychain can't be the only option
+ because headless Linux/containers/CI have no Secret Service. Whichever backend is active, secret
+ *values* are never serialized to `.toml` and never land inside a workspace, so collections are
+ safe to commit.
- A small **custom** resolver (not a full template engine) keeps control over scoping and the
secret boundary.
- **Send-time policy (`engine::policy`).** `core::compose` clamps the env's TLS floor into the
@@ -235,7 +240,7 @@ upgrades are deliberate.
| GraphQL schema/validation | in-house lexer + schema model (no heavy parser dep) |
| Async runtime | `tokio` (+ `flume`/mpsc channels) |
| Persistence | `serde` + `toml` |
-| Secrets | `keyring` (OS keychain) |
+| Secrets | `keyring` (OS keychain, default) + file / env / helper-command backends |
| File watch | `notify` |
| OpenAPI import | untyped `serde_json` traversal (`posel-import`) |
| gRPC (post-v1) | `tonic` + `protox` / reflection |
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 3d221af..d9e9487 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -30,7 +30,8 @@ huge-payload and instant-feel advantages a web runtime structurally can't match.
2. **Multi-protocol depth** — REST, GraphQL, and tRPC today; gRPC and WebSocket/SSE next, all behind
one streaming execution model so each is first-class, not bolted on.
3. **Git-native & local-first** — collections are human-readable `.toml` on disk; collaboration is
- git. No backend, no account, ever. Secrets in the OS keychain, never committed.
+ git. No backend, no account, ever. Secrets resolved from a store outside the workspace (the OS
+ keychain by default), never committed.
4. **Keyboard-driven / Zed-like** — command palette, everything reachable without the mouse,
minimal chrome, instant startup.
@@ -74,7 +75,8 @@ Saying no is the plan. Posel will **not**:
### Organization & persistence
- **Collections** = a directory tree of `.toml` files (see §6), git-friendly.
- **Environments** + variables, with an active-environment switcher.
-- **Secrets** referenced by name, resolved from the OS keychain (never written to disk).
+- **Secrets** referenced by name, resolved at send time from a pluggable backend — the OS keychain
+ by default (never written into the workspace; see §6).
- **History** of sent requests.
- Open multiple requests as **tabs**; multiple workspaces.
@@ -133,8 +135,27 @@ file = "create-user.body.json" # or inline = """..."""
Rules:
- **Large/multi-line bodies** go in a sibling file (`*.body.*`) referenced by the request, so TOML
stays readable and diffs are clean.
-- **Secrets are never stored.** Environment values use a reference like `{{secret:token}}`,
- resolved at send time from the OS keychain. The `.toml` is safe to commit.
+- **Secret values never enter the workspace.** Environment values use a reference like
+ `{{secret:token}}`, resolved at send time from a **pluggable secret backend** configured under
+ `[secrets]` in the app settings file (`settings.toml` in the user data dir) and overridable per
+ run via `POSEL_SECRET_BACKEND` (or `posel-cli --secret-backend`). Whichever backend is active,
+ only the reference is written to `.toml`, so the collection is safe to commit. Backends:
+ - `keychain` *(default, writable)* — the OS keychain (macOS Keychain, Windows Credential
+ Manager, Linux Secret Service). The most secure option, but absent on headless Linux,
+ containers, and CI, which is why it can't be the only one.
+ - `file` *(writable)* — a `0600` TOML file in the user data dir, **outside any workspace**,
+ namespaced per workspace and written atomically. Plaintext at rest: the same posture as
+ `~/.aws/credentials` or `gh`'s `hosts.yml`, a deliberate trade so secrets work at all on a
+ machine with no keychain.
+ - `env` *(read-only)* — `{{secret:github_token}}` → `$POSEL_SECRET_GITHUB_TOKEN`; the prefix is
+ configurable (`env_prefix`). The CI choice.
+ - `command` *(read-only)* — an argv run per lookup whose stdout is the value, with `{name}`
+ substituted (`pass show posel/{name}`, `op read …`, `gh auth token`) — git-credential-helper
+ style, keeping values in the user's existing password manager.
+ - `none` — secret support off; every `{{secret:…}}` fails.
+
+ Read-only backends are owned by something outside Posel, so the UI surfaces their entries but
+ never offers to create or edit them.
- **Per-environment security policy (`[policy]` in `environments/*.toml`).** Opt-in per env, never
derived from an env's name/color: `enforce_tls_verify` and `require_https` floors (clamps — they
only tighten), an `allowed_hosts` egress list (exact or `*.wildcard`) gating where that env's
diff --git a/docs/assets/posel-demo.gif b/docs/assets/posel-demo.gif
new file mode 100644
index 0000000..21bf48d
Binary files /dev/null and b/docs/assets/posel-demo.gif differ
diff --git a/docs/assets/posel-demo.mp4 b/docs/assets/posel-demo.mp4
new file mode 100644
index 0000000..45ce888
Binary files /dev/null and b/docs/assets/posel-demo.mp4 differ