From ca60477b2271dfead015a33a2742f72fd843f117 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Sat, 13 Jun 2026 02:37:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(vault):=20M5=20slice=205=20=E2=80=94=20CLI?= =?UTF-8?q?=20agent=20auto-spawn=20+=20headless=20feature=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close out M5 with its two non-TUI items. Auto-spawn (PRD §7.3): any `vault` verb now starts `vault-agent` itself when the socket is dead (missing file or stale connection-refused socket; other errors, e.g. permissions, still surface directly). New vault-cli/src/spawn.rs locates the agent via $VAULT_AGENT_BIN, then a vault-agent sibling of the `vault` binary, then $PATH; starts it in its own process group (--socket passed explicitly, stdin/stdout null, stderr appended to agent.log beside the socket in the 0700 runtime dir); and poll-connects until the agent accepts (2 s deadline, 25 ms interval), reusing the first accepted stream. A new global --no-auto-spawn flag opts out per call, and stop-agent never spawns — stopping a dead agent must not start one. A small Endpoint { socket, auto_spawn } context replaces the bare &Path through the command handlers. Headless gate (PRD G6): the `vault` bin now carries required-features = ["cli"], making the documented server install literal: cargo install --path crates/vault-cli --no-default-features --features cli (pair with vault-agent --no-default-features to drop the clipboard's X11/Wayland tree). A new CI `headless` job builds both combos so the gate can't rot. Docs trued up: README Status (was still "M0 scaffolding only") and the headless install section; vault-agent's "run via nohup" module doc; CLAUDE.md's agent/cli/tui bullets and milestone state. Known limitation (tracked in CHANGELOG): two racing CLI invocations can each spawn an agent; the second bind steals the socket path and the first agent idles until its lock. Benign at single-user posture. Tests: binary-resolution precedence (override > sibling > $PATH, empty override ignored), dead-socket error classification, and poll-loop behavior (picks up a late listener, gives up at the deadline). Verified live end-to-end: spawn-on-first-verb, silent reuse, agent.log placement, --no-auto-spawn, and stop-agent-never-spawns. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 13 +++ CHANGELOG.md | 29 ++++++ Cargo.lock | 1 + README.md | 20 +++- crates/vault-agent/src/main.rs | 7 +- crates/vault-cli/Cargo.toml | 7 ++ crates/vault-cli/src/main.rs | 135 ++++++++++++++---------- crates/vault-cli/src/spawn.rs | 184 +++++++++++++++++++++++++++++++++ 8 files changed, 336 insertions(+), 60 deletions(-) create mode 100644 crates/vault-cli/src/spawn.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ca0b7a..6e367fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,19 @@ jobs: - run: cargo build --workspace --all-targets - run: cargo test --workspace --all-targets + headless: + name: headless build (no TUI / clipboard deps) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # PRD G6: the server install path must keep building — the CLI behind + # its `cli` feature gate, and the agent without the clipboard's + # X11/Wayland dependency tree. + - run: cargo build -p vault-cli --no-default-features --features cli + - run: cargo build -p vault-agent --no-default-features + version-gate: name: vault --version emits §13.2 attribution runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aacce7..2213044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ range may break in any release. ### Added +- **M5 (slice 5) — CLI agent auto-spawn + headless feature gate.** The two + non-TUI M5 items, closing out the milestone. + - **Auto-spawn (PRD §7.3).** Any `vault` verb now starts `vault-agent` + itself when the socket is dead (missing file or stale, connection-refused + socket — other errors, e.g. permissions, still surface directly). The CLI + locates the agent via `$VAULT_AGENT_BIN`, then a `vault-agent` sibling of + the `vault` binary, then `$PATH`; starts it in its own process group + (`--socket` passed explicitly, stdin/stdout null, stderr appended to + `agent.log` beside the socket in the 0700 runtime dir); and poll-connects + until the agent accepts (2 s deadline, 25 ms interval), reusing the first + accepted stream. Opt out per-call with the global `--no-auto-spawn`; + `stop-agent` never spawns (stopping a dead agent shouldn't start one). + New `vault-cli/src/spawn.rs` module; the old "start the daemon with + `vault-agent &`" hint remains only on the no-spawn paths. + - **Headless gate (PRD G6).** The `vault` bin now carries + `required-features = ["cli"]`, making the documented server install + literal: `cargo install --path crates/vault-cli --no-default-features + --features cli` (pair with `cargo install --path crates/vault-agent + --no-default-features` to drop the clipboard's X11/Wayland tree). A new + CI `headless` job builds both combos so the gate can't rot. README's + Status and headless sections updated to match reality. + - Known limitation: two racing CLI invocations can each spawn an agent; the + second bind steals the socket path (the listener removes a pre-existing + socket file) and the first agent is orphaned until its idle lock. Benign + for the single-user posture; a flock around spawn is a possible follow-up. + - Tests: binary-resolution precedence (override > sibling > `$PATH`, + empty override ignored), dead-socket error classification, and poll-loop + behavior (picks up a late listener; gives up at the deadline). + - **M5 (slice 4) — TUI mutations: `a` add, `e` edit, `d` delete (confirm).** PRD §7.2's Mutation row goes live, completing the daily-driver loop (browse → search → reveal/copy → mutate) without falling back to the CLI. Pure TUI diff --git a/Cargo.lock b/Cargo.lock index 3a0a2e6..6d1d1be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2428,6 +2428,7 @@ dependencies = [ "anyhow", "clap", "serde_json", + "tempfile", "tokio", "vault-core", "vault-ipc", diff --git a/README.md b/README.md index 52969f1..56ff825 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,14 @@ The full product requirements live in [`PRD.md`](./PRD.md). ## Status -Pre-alpha — M0 scaffolding only. The `vault` binary builds and emits the -Standard §13.2 attribution block via `--version`; nothing else is wired up -yet. See [PRD §12](./PRD.md#12-milestones) for the roadmap (M0 → v0.1). +Pre-alpha, M5. The read and write paths are wired end-to-end against a live +agent: `status` / `unlock` / `lock` / `sync` / `list` / `get` / `add` / +`edit` / `remove` / `generate` / `stop-agent` on the CLI (with `--json` +everywhere), and a three-pane `vault-tui` with search, reveal/copy +(agent-side clipboard, 30 s auto-clear), a generator overlay, a `:` command +line, and add/edit/delete. The CLI auto-starts the agent when needed. See +[PRD §12](./PRD.md#12-milestones) for the roadmap (M0 → v0.1) and +[`CHANGELOG.md`](./CHANGELOG.md) for per-slice detail. ## Build @@ -26,12 +31,19 @@ cargo build --release ./target/release/vault --version ``` -Headless install (no TUI dependencies) — once the feature gate lands in M5: +Headless install (no TUI dependencies; the agent additionally drops the +clipboard's X11/Wayland tree): ```sh cargo install --path crates/vault-cli --no-default-features --features cli +cargo install --path crates/vault-agent --no-default-features ``` +The CLI auto-starts `vault-agent` when the socket is dead: it looks for a +sibling of the `vault` binary, then `$PATH` (override with +`$VAULT_AGENT_BIN`; opt out per-call with `--no-auto-spawn`). A spawned +agent logs to `agent.log` beside the socket. + ## Repository layout ``` diff --git a/crates/vault-agent/src/main.rs b/crates/vault-agent/src/main.rs index 4257e2c..6bc3f6c 100644 --- a/crates/vault-agent/src/main.rs +++ b/crates/vault-agent/src/main.rs @@ -4,9 +4,10 @@ //! //! Run with no arguments to bind the default socket //! (`$XDG_RUNTIME_DIR/vault/agent.sock`). Override with `--socket PATH` or -//! `VAULT_AGENT_SOCK`. The agent does NOT yet daemonise itself — start it -//! with `nohup vault-agent &` or run it under a systemd user unit. M5 adds -//! auto-spawn from the CLI. +//! `VAULT_AGENT_SOCK`. The agent does not daemonise itself: the normal start +//! path is the CLI's auto-spawn — any `vault` verb starts it detached when +//! the socket is dead, logging to `agent.log` beside the socket — and a +//! systemd user unit works too. #![forbid(unsafe_code)] diff --git a/crates/vault-cli/Cargo.toml b/crates/vault-cli/Cargo.toml index bacea5f..de6b1ff 100644 --- a/crates/vault-cli/Cargo.toml +++ b/crates/vault-cli/Cargo.toml @@ -16,6 +16,10 @@ readme.workspace = true [[bin]] name = "vault" path = "src/main.rs" +# The headless gate (PRD G6): `cargo install --path crates/vault-cli +# --no-default-features --features cli` is the supported server install; +# with no features at all there is nothing to build. +required-features = ["cli"] [lints] workspace = true @@ -24,6 +28,9 @@ workspace = true default = ["cli"] cli = [] +[dev-dependencies] +tempfile = { workspace = true } + [dependencies] anyhow = { workspace = true } clap = { workspace = true } diff --git a/crates/vault-cli/src/main.rs b/crates/vault-cli/src/main.rs index e6b021d..625745b 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/vault-cli/src/main.rs @@ -2,15 +2,18 @@ //! Vault CLI — `vault` binary entry point. //! -//! M3 surface: `status`, `unlock`, `lock`, `sync`, `list`, `get`, `stop-agent`. //! Every subcommand opens a fresh UDS connection to the agent, sends one -//! CBOR-framed request, and prints the response. The CLI never touches the -//! master key directly — it is only relayed to the agent during `unlock`. +//! CBOR-framed request, and prints the response. When the socket is dead the +//! CLI auto-starts `vault-agent` first (PRD §7.3; disable with +//! `--no-auto-spawn`). The CLI never touches the master key directly — it is +//! only relayed to the agent during `unlock`. #![forbid(unsafe_code)] +mod spawn; + use std::io::{self, BufRead, IsTerminal, Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand}; use tokio::net::UnixStream; @@ -57,10 +60,35 @@ struct Cli { #[arg(long, global = true)] socket: Option, + /// Do not auto-start `vault-agent` when the socket is dead. + #[arg(long, global = true)] + no_auto_spawn: bool, + #[command(subcommand)] cmd: Option, } +/// Resolved agent endpoint: where the socket lives and whether a dead socket +/// should auto-start `vault-agent` (PRD §7.3). +#[derive(Clone, Copy, Debug)] +struct Endpoint<'a> { + /// Socket path the agent is (or will be) bound to. + socket: &'a Path, + /// Start the agent when nothing is accepting on `socket`. + auto_spawn: bool, +} + +impl Endpoint<'_> { + /// The same endpoint with auto-spawn off — `stop-agent` must never start + /// an agent just to stop it. + const fn no_spawn(self) -> Self { + Self { + auto_spawn: false, + ..self + } + } +} + #[derive(Subcommand, Debug)] enum Cmd { /// Show agent state (unlocked? bound to which account? item count?). @@ -262,7 +290,11 @@ async fn main() -> std::process::ExitCode { eprintln!("vault: missing subcommand. Try `vault --help`."); return std::process::ExitCode::from(2); }; - match run(cmd, &socket).await { + let ep = Endpoint { + socket: &socket, + auto_spawn: !cli.no_auto_spawn, + }; + match run(cmd, ep).await { Ok(()) => std::process::ExitCode::SUCCESS, Err(code) => std::process::ExitCode::from(code), } @@ -282,18 +314,18 @@ fn resolve_socket(cli: Option) -> anyhow::Result { default_socket_path().ok_or_else(|| anyhow::anyhow!("no XDG_RUNTIME_DIR / TMPDIR available")) } -async fn run(cmd: Cmd, socket: &std::path::Path) -> Result<(), u8> { +async fn run(cmd: Cmd, ep: Endpoint<'_>) -> Result<(), u8> { match cmd { - Cmd::Status { json } => cmd_status(socket, json).await, + Cmd::Status { json } => cmd_status(ep, json).await, Cmd::Unlock { server, email, json, - } => cmd_unlock(socket, server, email, json).await, - Cmd::Lock { json } => cmd_ack(socket, Request::Lock, "locked", json).await, - Cmd::Sync { json } => cmd_sync(socket, json).await, - Cmd::List { json } => cmd_list(socket, json).await, - Cmd::Get { name, field, json } => cmd_get(socket, name, field.into(), json).await, + } => cmd_unlock(ep, server, email, json).await, + Cmd::Lock { json } => cmd_ack(ep, Request::Lock, "locked", json).await, + Cmd::Sync { json } => cmd_sync(ep, json).await, + Cmd::List { json } => cmd_list(ep, json).await, + Cmd::Get { name, field, json } => cmd_get(ep, name, field.into(), json).await, Cmd::Add { name, kind, @@ -305,7 +337,7 @@ async fn run(cmd: Cmd, socket: &std::path::Path) -> Result<(), u8> { json, } => { cmd_add( - socket, + ep, AddArgs { name, kind, @@ -331,7 +363,7 @@ async fn run(cmd: Cmd, socket: &std::path::Path) -> Result<(), u8> { json, } => { cmd_edit( - socket, + ep, EditArgs { selector, name, @@ -350,8 +382,8 @@ async fn run(cmd: Cmd, socket: &std::path::Path) -> Result<(), u8> { selector, force, json, - } => cmd_remove(socket, selector, force, json).await, - Cmd::StopAgent { json } => cmd_ack(socket, Request::Quit, "stopped", json).await, + } => cmd_remove(ep, selector, force, json).await, + Cmd::StopAgent { json } => cmd_ack(ep.no_spawn(), Request::Quit, "stopped", json).await, Cmd::Generate { length, symbols, @@ -404,8 +436,8 @@ fn cmd_generate( Ok(()) } -async fn cmd_status(socket: &std::path::Path, json: bool) -> Result<(), u8> { - let mut stream = connect(socket).await?; +async fn cmd_status(ep: Endpoint<'_>, json: bool) -> Result<(), u8> { + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &Request::Status).await?; match resp { Response::Status(s) => { @@ -418,7 +450,7 @@ async fn cmd_status(socket: &std::path::Path, json: bool) -> Result<(), u8> { } async fn cmd_unlock( - socket: &std::path::Path, + ep: Endpoint<'_>, server: Option, email: Option, json: bool, @@ -426,7 +458,7 @@ async fn cmd_unlock( let server = resolve_arg(server, "VAULT_SERVER", "--server")?; let email = resolve_arg(email, "VAULT_EMAIL", "--email")?; let password = read_password()?; - let mut stream = connect(socket).await?; + let mut stream = connect(ep).await?; let req = Request::Unlock { server, email, @@ -450,13 +482,8 @@ async fn cmd_unlock( /// Fire-and-acknowledge: send `req`, expect a bare `Ok`, and (only under /// `--json`) print a `{ "": true }` envelope. Human mode stays silent /// on success, matching the pre-`--json` behaviour of `lock`/`stop-agent`. -async fn cmd_ack( - socket: &std::path::Path, - req: Request, - action: &str, - json: bool, -) -> Result<(), u8> { - let mut stream = connect(socket).await?; +async fn cmd_ack(ep: Endpoint<'_>, req: Request, action: &str, json: bool) -> Result<(), u8> { + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &req).await?; match resp { Response::Ok => { @@ -468,8 +495,8 @@ async fn cmd_ack( } } -async fn cmd_sync(socket: &std::path::Path, json: bool) -> Result<(), u8> { - let mut stream = connect(socket).await?; +async fn cmd_sync(ep: Endpoint<'_>, json: bool) -> Result<(), u8> { + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &Request::Sync).await?; match resp { // The agent answers a successful re-sync with a fresh Status snapshot. @@ -495,8 +522,8 @@ async fn cmd_sync(socket: &std::path::Path, json: bool) -> Result<(), u8> { } } -async fn cmd_list(socket: &std::path::Path, json: bool) -> Result<(), u8> { - let mut stream = connect(socket).await?; +async fn cmd_list(ep: Endpoint<'_>, json: bool) -> Result<(), u8> { + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &Request::List).await?; match resp { Response::List(items) => { @@ -508,12 +535,7 @@ async fn cmd_list(socket: &std::path::Path, json: bool) -> Result<(), u8> { } } -async fn cmd_remove( - socket: &std::path::Path, - selector: String, - force: bool, - json: bool, -) -> Result<(), u8> { +async fn cmd_remove(ep: Endpoint<'_>, selector: String, force: bool, json: bool) -> Result<(), u8> { if !force { if !io::stdin().is_terminal() { eprintln!("vault: refusing to remove without --force when stdin is not a TTY"); @@ -535,7 +557,7 @@ async fn cmd_remove( return Err(2); } } - let mut stream = connect(socket).await?; + let mut stream = connect(ep).await?; let req = Request::Remove { selector }; let resp = exchange(&mut stream, &req).await?; match resp { @@ -560,7 +582,7 @@ struct AddArgs { json: bool, } -async fn cmd_add(socket: &std::path::Path, args: AddArgs) -> Result<(), u8> { +async fn cmd_add(ep: Endpoint<'_>, args: AddArgs) -> Result<(), u8> { let cipher_type = args.kind.cipher_type(); let is_login = matches!(args.kind, KindArg::Login); @@ -595,7 +617,7 @@ async fn cmd_add(socket: &std::path::Path, args: AddArgs) -> Result<(), u8> { totp: None, uri, }; - let mut stream = connect(socket).await?; + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &req).await?; match resp { Response::Saved(s) => { @@ -620,7 +642,7 @@ struct EditArgs { json: bool, } -async fn cmd_edit(socket: &std::path::Path, args: EditArgs) -> Result<(), u8> { +async fn cmd_edit(ep: Endpoint<'_>, args: EditArgs) -> Result<(), u8> { let mut generated: Option> = None; let password = if let Some(len) = args.generate { let pw = generate_pw(len)?; @@ -647,7 +669,7 @@ async fn cmd_edit(socket: &std::path::Path, args: EditArgs) -> Result<(), u8> { totp: None, uri: args.uri, }; - let mut stream = connect(socket).await?; + let mut stream = connect(ep).await?; let resp = exchange(&mut stream, &req).await?; match resp { Response::Saved(s) => { @@ -701,13 +723,8 @@ fn read_secret(prompt: &str) -> Result>, u8> { Ok(Some(bytes)) } -async fn cmd_get( - socket: &std::path::Path, - name: String, - field: Field, - json: bool, -) -> Result<(), u8> { - let mut stream = connect(socket).await?; +async fn cmd_get(ep: Endpoint<'_>, name: String, field: Field, json: bool) -> Result<(), u8> { + let mut stream = connect(ep).await?; let req = Request::Get { // The CLI selects by name only; id-targeting is a TUI affordance. id: None, @@ -725,14 +742,26 @@ async fn cmd_get( } } -async fn connect(socket: &std::path::Path) -> Result { - match UnixStream::connect(socket).await { +async fn connect(ep: Endpoint<'_>) -> Result { + match UnixStream::connect(ep.socket).await { Ok(s) => Ok(s), + // A missing or stale socket means no live agent — start one (PRD + // §7.3) unless the user opted out. + Err(e) if ep.auto_spawn && spawn::socket_is_dead(&e) => { + spawn::spawn_and_connect(ep.socket).await.map_err(|msg| { + eprintln!( + "vault: {msg}\n\ + vault: could not connect to agent at {}: {e}", + ep.socket.display() + ); + 3 + }) + } Err(e) => { eprintln!( "vault: could not connect to agent at {}: {e}\n\ - hint: start the daemon with `vault-agent &` first.", - socket.display() + hint: start the daemon with `vault-agent &` if it is not running.", + ep.socket.display() ); Err(3) } diff --git a/crates/vault-cli/src/spawn.rs b/crates/vault-cli/src/spawn.rs new file mode 100644 index 0000000..736768a --- /dev/null +++ b/crates/vault-cli/src/spawn.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Agent auto-spawn — start `vault-agent` when the socket is dead (PRD §7.3). +//! +//! The CLI is the daemon's launcher of last resort: when a connect fails with +//! "not found" / "connection refused", it locates the agent binary, starts it +//! detached (own process group, stdout/stderr appended to `agent.log` beside +//! the socket), and polls the socket until the agent accepts. The agent still +//! binds and chmods the socket itself — the CLI only waits for it. Disable +//! with the global `--no-auto-spawn` flag. + +use std::ffi::OsString; +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use tokio::net::UnixStream; + +/// How long to wait for a freshly spawned agent to accept connections. +/// Generous against a cold page cache; a warm spawn binds in single-digit +/// milliseconds, and the poll loop returns as soon as the connect lands. +const SPAWN_DEADLINE: Duration = Duration::from_secs(2); + +/// Poll interval while waiting for the socket to come up — small enough to +/// keep the PRD §9 warm-`get` budget plausible on the first post-spawn call. +const SPAWN_POLL: Duration = Duration::from_millis(25); + +/// Whether `e` means "no live agent behind this path" — a missing socket file +/// or a stale one nobody is accepting on. These are the only two outcomes +/// worth a spawn attempt; anything else (e.g. permissions) needs the user. +pub fn socket_is_dead(e: &io::Error) -> bool { + matches!( + e.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) +} + +/// Locate the agent binary: `$VAULT_AGENT_BIN` override first, then a +/// `vault-agent` sibling of the current executable (the layout both +/// `cargo build` and `cargo install` produce), then bare `vault-agent` +/// resolved through `$PATH`. +fn agent_binary() -> PathBuf { + let sibling = std::env::current_exe().ok().and_then(|exe| { + let p = exe.parent()?.join("vault-agent"); + p.is_file().then_some(p) + }); + resolve_binary(std::env::var_os("VAULT_AGENT_BIN"), sibling) +} + +/// Pure precedence behind [`agent_binary`]: a non-empty override wins, then +/// the pre-checked sibling, then `$PATH` lookup by name. +fn resolve_binary(overridden: Option, sibling: Option) -> PathBuf { + if let Some(p) = overridden + && !p.is_empty() + { + return PathBuf::from(p); + } + sibling.unwrap_or_else(|| PathBuf::from("vault-agent")) +} + +/// Start the agent for `socket` and wait for it to accept. Returns the first +/// accepted stream so the caller doesn't race a second connect. +/// +/// # Errors +/// +/// Returns a user-facing message when the binary can't be started or the +/// agent doesn't come up within the deadline. +pub async fn spawn_and_connect(socket: &Path) -> Result { + let bin = agent_binary(); + let child = Command::new(&bin) + .arg("--socket") + .arg(socket) + // Own process group: a Ctrl+C aimed at the CLI must not take the + // freshly started daemon down with it. + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(agent_log(socket)) + .spawn() + .map_err(|e| format!("could not start {}: {e}", bin.display()))?; + eprintln!("vault: started vault-agent (pid {})", child.id()); + await_socket(socket, SPAWN_DEADLINE).await.ok_or_else(|| { + format!( + "vault-agent did not come up within {}s — see agent.log next to the socket", + SPAWN_DEADLINE.as_secs() + ) + }) +} + +/// Where the spawned agent's stderr goes: `agent.log` beside the socket, in +/// the directory the agent will chmod 0700 on bind (pre-created here with the +/// same mode so the log never sits in a wider directory). Falls back to null +/// rather than failing the spawn over logging. +fn agent_log(socket: &Path) -> Stdio { + let Some(dir) = socket.parent() else { + return Stdio::null(); + }; + fs::create_dir_all(dir) + .and_then(|()| fs::set_permissions(dir, fs::Permissions::from_mode(0o700))) + .and_then(|()| { + fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("agent.log")) + }) + .map_or_else(|_| Stdio::null(), Stdio::from) +} + +/// Poll-connect until the socket accepts or the deadline passes. +async fn await_socket(socket: &Path, deadline: Duration) -> Option { + let start = std::time::Instant::now(); + while start.elapsed() < deadline { + if let Ok(s) = UnixStream::connect(socket).await { + return Some(s); + } + tokio::time::sleep(SPAWN_POLL).await; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_binary_prefers_override_then_sibling_then_path() { + let over = Some(OsString::from("/opt/vault-agent")); + let sib = Some(PathBuf::from("/bin-dir/vault-agent")); + assert_eq!( + resolve_binary(over, sib.clone()), + PathBuf::from("/opt/vault-agent") + ); + // An empty override is treated as unset. + assert_eq!( + resolve_binary(Some(OsString::new()), sib.clone()), + PathBuf::from("/bin-dir/vault-agent") + ); + assert_eq!( + resolve_binary(None, sib), + PathBuf::from("/bin-dir/vault-agent") + ); + assert_eq!(resolve_binary(None, None), PathBuf::from("vault-agent")); + } + + #[test] + fn socket_is_dead_only_for_missing_or_refused() { + assert!(socket_is_dead(&io::Error::from(io::ErrorKind::NotFound))); + assert!(socket_is_dead(&io::Error::from( + io::ErrorKind::ConnectionRefused + ))); + assert!(!socket_is_dead(&io::Error::from( + io::ErrorKind::PermissionDenied + ))); + } + + /// The poll loop must pick up a socket that starts accepting *after* the + /// first attempt — that's the whole point of polling. + #[tokio::test(flavor = "current_thread")] + async fn await_socket_picks_up_a_late_listener() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("agent.sock"); + let bind_path = path.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(60)).await; + let listener = tokio::net::UnixListener::bind(&bind_path).expect("bind"); + // Hold the listener long enough for the poll loop to connect. + let _ = listener.accept().await; + }); + let got = await_socket(&path, Duration::from_secs(2)).await; + assert!(got.is_some(), "poll loop missed the late listener"); + } + + #[tokio::test(flavor = "current_thread")] + async fn await_socket_gives_up_after_the_deadline() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("never.sock"); + let got = await_socket(&path, Duration::from_millis(80)).await; + assert!(got.is_none()); + } +}