diff --git a/Cargo.lock b/Cargo.lock index 4788755..daabef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "difflib" version = "0.4.0" @@ -355,6 +361,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -931,6 +958,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "libyaml-rs" version = "0.3.0" @@ -999,6 +1035,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1036,6 +1078,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "4.3.0" @@ -1062,6 +1110,7 @@ dependencies = [ "assert_cmd", "clap", "csv", + "dirs", "futures", "globset", "httpdate", @@ -1075,6 +1124,7 @@ dependencies = [ "sha2", "similar", "tempfile", + "time", "tokio", "toml", "wiremock", @@ -1090,6 +1140,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1238,6 +1294,17 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.4" @@ -1696,6 +1763,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index a08752a..98e43ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ qualification-candidate = [] [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } +dirs = "6.0.0" futures = "0.3.32" globset = "0.4.18" httpdate = "1.0.3" @@ -35,6 +36,7 @@ serde_json = { version = "1.0.150", features = ["raw_value"] } sha2 = "0.11.0" similar = "3.1.1" tempfile = "3.27.0" +time = { version = "0.3.54", features = ["parsing", "formatting"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "time"] } toml = "1.1.2" yaml_serde = "0.10.4" diff --git a/README.md b/README.md index 693aa2f..1bdf14d 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Release binaries cover Linux x86_64 and ARM64 with glibc or musl, plus macOS on ## Review before pushing +Authenticate once for hosted inference against your organization's entitlement, or bring your own model key: + ```sh -export MODEL_API_KEY=... # OpenRouter is the default endpoint -export REVIEW_MODEL=provider/qualified-model +postil login # zero-config: stores a credential for hosted inference +# or: export MODEL_API_KEY=... # OpenRouter is the default endpoint +# export REVIEW_MODEL=provider/qualified-model postil doctor # validate the endpoint and repository postil review --staged # review the staged change postil review --base origin/main # review the branch diff --git a/docs/configuration.md b/docs/configuration.md index 0afddda..f031101 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,7 @@ Place organization-specific merge rules in `.postil/guardrails.md`. Place additi | Variable | Purpose | | --- | --- | | `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, `LLM_API_KEY` | Model provider credential, checked in that order | +| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`/`postil logout`; defaults to `https://postil.dev` | | `POSTIL_API_BASE` | Model endpoint selected by the operator | | `POSTIL_API_FORMAT` | `openai-compatible` or `anthropic` | | `POSTIL_ENDPOINT_AUTH_HEADER`, `POSTIL_ENDPOINT_AUTH_VALUE` | Additional private-gateway authentication | @@ -64,6 +65,15 @@ Place organization-specific merge rules in `.postil/guardrails.md`. Place additi Forge credentials and base URLs are listed in [Code forges](forges.md). Provider-specific behavior is in [Model providers](model-providers.md). +## Login + +```sh +postil login +postil logout +``` + +`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the credential server-side and removes the local file even if that call fails. An expired credential produces one instruction to run `postil login` again rather than a provider authentication error. + ## Inspect and initialize ```sh @@ -72,4 +82,4 @@ postil config postil doctor ``` -`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values. +`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values, and reports whether a login credential is present and when it expires. diff --git a/src/api_key.rs b/src/api_key.rs index 2df6dd2..060f2f8 100644 --- a/src/api_key.rs +++ b/src/api_key.rs @@ -1,4 +1,16 @@ //! Inference API-key resolution shared by CLI runtime checks. +//! +//! [`resolve_from_process_env`] resolves only the four explicit env vars, in +//! priority order. [`resolve_effective`] adds one more source below all +//! four: a stored `postil login` credential, used only when none of the env +//! vars is set. See `config.rs`'s module doc for the full precedence +//! statement. + +use std::path::Path; + +use anyhow::Result; + +use crate::credentials::{self, Credentials}; pub(crate) const API_KEY_ENV_VARS: [&str; 4] = [ "POSTIL_API_KEY", @@ -21,6 +33,37 @@ pub(crate) fn resolve_with(mut lookup: impl FnMut(&str) -> Option) -> Op .find_map(|name| lookup(name).filter(|value| !value.trim().is_empty())) } +/// Bearer key resolved for inference, falling back to a stored `postil +/// login` credential when none of the four explicit env vars is set. +/// `Ok(None)` means neither source has a key; callers turn that into their +/// own "set a key or run `postil login`" message. An expired stored +/// credential is reported here as an error, so the caller surfaces one +/// actionable instruction instead of a confusing upstream auth failure once +/// the request reaches the provider. +pub(crate) fn resolve_effective(credentials_path: &Path) -> Result> { + resolve_effective_with( + |name| std::env::var(name).ok(), + || credentials::read(credentials_path), + ) +} + +pub(crate) fn resolve_effective_with( + env_lookup: impl FnMut(&str) -> Option, + credential_lookup: impl FnOnce() -> Result>, +) -> Result> { + if let Some(key) = resolve_with(env_lookup) { + return Ok(Some(key)); + } + let Some(credentials) = credential_lookup()? else { + return Ok(None); + }; + anyhow::ensure!( + !credentials.is_expired(), + "the stored postil login credential expired; run `postil login` again" + ); + Ok(Some(credentials.token)) +} + #[cfg(test)] mod tests { use super::*; @@ -63,4 +106,66 @@ mod tests { Some("llm-key".to_string()) ); } + + fn stored_credential(expires_at: &str) -> Credentials { + Credentials { + version: credentials::CREDENTIALS_VERSION, + token: "pcli_stored-token-not-a-real-secret".to_string(), + expires_at: expires_at.to_string(), + api_base: "https://postil.dev/api/inference/v1".to_string(), + org: "runatlas-is".to_string(), + model: "z-ai/glm-5.2".to_string(), + } + } + + fn resolve_effective( + pairs: &[(&str, &str)], + stored: Option, + ) -> Result> { + let values: HashMap<&str, &str> = pairs.iter().copied().collect(); + resolve_effective_with( + |name| values.get(name).map(|value| (*value).to_string()), + || Ok(stored), + ) + } + + #[test] + fn each_explicit_env_var_wins_over_a_stored_credential() { + for name in API_KEY_ENV_VARS { + let resolved = resolve_effective( + &[(name, "explicit-key")], + Some(stored_credential("2999-01-01T00:00:00.000Z")), + ) + .unwrap(); + assert_eq!( + resolved, + Some("explicit-key".to_string()), + "{name} did not take priority over a stored credential" + ); + } + } + + #[test] + fn stored_credential_is_used_when_no_env_var_is_set() { + let resolved = + resolve_effective(&[], Some(stored_credential("2999-01-01T00:00:00.000Z"))).unwrap(); + assert_eq!( + resolved, + Some("pcli_stored-token-not-a-real-secret".to_string()) + ); + } + + #[test] + fn absence_of_both_env_var_and_credential_resolves_to_none() { + assert_eq!(resolve_effective(&[], None).unwrap(), None); + } + + #[test] + fn expired_stored_credential_yields_one_actionable_relogin_error() { + let error = resolve_effective(&[], Some(stored_credential("2020-01-01T00:00:00.000Z"))) + .expect_err("expired credential must error, not silently resolve"); + let message = error.to_string(); + assert!(message.contains("postil login")); + assert!(!message.contains("pcli_stored-token-not-a-real-secret")); + } } diff --git a/src/cli.rs b/src/cli.rs index b764e9c..7155fde 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -203,6 +203,15 @@ pub enum Command { #[command(subcommand)] action: HookAction, }, + /// Authenticate against postil.dev for zero-config hosted inference. + Login { + /// Organization to select during approval. The browser approval page + /// is authoritative for membership; this only pre-fills a hint. + #[arg(long)] + org: Option, + }, + /// Remove the stored login credential and revoke it server-side. + Logout, } #[derive(Subcommand)] @@ -346,4 +355,26 @@ mod tests { .is_err() ); } + + #[test] + fn login_accepts_an_optional_org_hint() { + let parsed = Cli::try_parse_from(["postil", "login"]).unwrap(); + let Command::Login { org } = parsed.command else { + panic!("expected login command"); + }; + assert_eq!(org, None); + + let parsed = Cli::try_parse_from(["postil", "login", "--org", "runatlas-is"]).unwrap(); + let Command::Login { org } = parsed.command else { + panic!("expected login command"); + }; + assert_eq!(org.as_deref(), Some("runatlas-is")); + } + + #[test] + fn logout_takes_no_arguments() { + let parsed = Cli::try_parse_from(["postil", "logout"]).unwrap(); + assert!(matches!(parsed.command, Command::Logout)); + assert!(Cli::try_parse_from(["postil", "logout", "--org", "runatlas-is"]).is_err()); + } } diff --git a/src/config.rs b/src/config.rs index 036603b..8029491 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,19 @@ //! resolved base URL receives the deployment's bearer key. It is ignored by //! default and honored only when `POSTIL_ALLOW_CONFIG_API_BASE=1` (single-user //! local setups with a trusted repo). `POSTIL_API_BASE` (env) always applies. +//! +//! Inference credential: `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, +//! `MODEL_API_KEY`, and `LLM_API_KEY` (first set wins) all take priority over +//! a stored `postil login` credential. When none of those four is set and a +//! valid, unexpired credential exists at +//! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, its token becomes +//! the bearer key (see `api_key::resolve_effective`) and its `apiBase`/ +//! `model` replace the values resolved above -- unless `POSTIL_API_BASE` / +//! `REVIEW_MODEL` are set, which still win, applied first in this same +//! function. An expired or unreadable stored credential is left alone here; +//! `resolve_api_key` reports it as one actionable "run `postil login` again" +//! error instead of blocking commands, like `postil config`, that never need +//! a live key. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -15,6 +28,8 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::api_key; +use crate::credentials; use crate::envelope::{Kind, Severity}; const MODEL_DEFAULTS_TOML: &str = include_str!("../config.toml"); @@ -1438,9 +1453,43 @@ impl Config { { self.api_format = ApiFormat::parse(&format)?; } + self.apply_stored_login_credential(); Ok(()) } + /// A first-time `postil login` user gets working defaults with zero + /// `.postil.yaml`: when no explicit key env var is set, a stored + /// credential supplies `apiBase` and `model`, unless `POSTIL_API_BASE`/ + /// `REVIEW_MODEL` (just applied above) already overrode them. The + /// cascade is cleared along with `model`, since a leftover BYOK cascade + /// names models the hosted gateway does not operate. + /// + /// This applies even when the credential is expired: routing + /// (`apiBase`/`model`) is not a secret, and populating it here is what + /// lets `require_model` pass and `postil doctor`'s checks actually run + /// instead of failing before they can report the expiry. The live key + /// resolution in `resolve_api_key` is the sole place that enforces and + /// reports expiry -- it still refuses the token regardless of what + /// happens here. + fn apply_stored_login_credential(&mut self) { + if api_key::resolve_from_process_env().is_some() { + return; + } + let Ok(path) = credentials::default_path() else { + return; + }; + let Ok(Some(creds)) = credentials::read(&path) else { + return; + }; + if std::env::var("POSTIL_API_BASE").is_err() { + self.api_base = creds.api_base; + } + if std::env::var("REVIEW_MODEL").is_err() { + self.model = creds.model; + self.cascade.clear(); + } + } + /// All models to try, in order, deduplicated. pub fn model_chain(&self) -> Vec { let mut chain = Vec::new(); diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..ac26515 --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,226 @@ +//! Stored `postil login` credentials. +//! +//! Lives outside any repository, at +//! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, because it is a +//! developer secret bound to one machine, not project configuration. The +//! bearer token inside spends an organization's hosted-inference +//! entitlement, so both the containing directory (0700) and the file itself +//! (0600) get their final permission bits set at creation time -- never +//! `chmod`'d afterward, so there is no window where the token is briefly +//! world-readable. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +pub const CREDENTIALS_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Credentials { + pub version: u32, + pub token: String, + #[serde(rename = "expiresAt")] + pub expires_at: String, + #[serde(rename = "apiBase")] + pub api_base: String, + pub org: String, + pub model: String, +} + +impl Credentials { + /// Fails closed: an `expiresAt` that will not parse is treated as + /// already expired rather than trusted, so a corrupted file never grants + /// silent access. + pub fn is_expired(&self) -> bool { + match time::OffsetDateTime::parse( + &self.expires_at, + &time::format_description::well_known::Rfc3339, + ) { + Ok(expires_at) => expires_at <= time::OffsetDateTime::now_utc(), + Err(_) => true, + } + } +} + +/// The real, XDG-resolved path used by `postil login`/`postil logout` and by +/// runtime credential resolution. `dirs::config_dir()` is deliberately not +/// used here: on macOS it resolves to `~/Library/Application Support`, but +/// the login contract fixes the location at `${XDG_CONFIG_HOME:-~/.config}` +/// on every platform, matching how other developer CLIs (not just desktop +/// apps) place their config on a Mac. Tests exercise `read`/`write`/`remove` +/// directly against a temp-directory path instead of calling this, so +/// nothing about credential handling itself depends on process-wide state. +pub fn default_path() -> Result { + let config_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| dirs::home_dir().map(|home| home.join(".config"))) + .context("cannot determine a config directory: set XDG_CONFIG_HOME or HOME")?; + Ok(config_dir.join("postil").join("credentials.json")) +} + +/// `Ok(None)` when no credential is stored yet; `Err` only for a file that +/// exists but cannot be read or parsed. +pub fn read(path: &Path) -> Result> { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + }; + let credentials: Credentials = + serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?; + Ok(Some(credentials)) +} + +/// Writes atomically: the temp file is created with its final permission +/// bits already set, filled, `fsync`'d, then renamed into place, so a reader +/// never observes a partially written or briefly world-readable file. +pub fn write(path: &Path, credentials: &Credentials) -> Result<()> { + let parent = path + .parent() + .context("credentials path must have a parent directory")?; + create_private_dir(parent)?; + let temp_path = parent.join(format!(".credentials.{}.tmp", std::process::id())); + let _ = fs::remove_file(&temp_path); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let result = (|| -> Result<()> { + let mut file = options + .open(&temp_path) + .context("creating private credentials file")?; + serde_json::to_writer_pretty(&mut file, credentials).context("serializing credentials")?; + file.write_all(b"\n").context("writing credentials")?; + file.sync_all().context("syncing credentials")?; + fs::rename(&temp_path, path).context("installing credentials file")?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +/// Idempotent: removing an already-absent file is success, matching the +/// server-side logout endpoint's own idempotence. +pub fn remove(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), + } +} + +#[cfg(unix)] +fn create_private_dir(dir: &Path) -> Result<()> { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("creating {}", dir.display())) +} + +#[cfg(not(unix))] +fn create_private_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(expires_at: &str) -> Credentials { + Credentials { + version: CREDENTIALS_VERSION, + token: "pcli_test-token-not-a-real-secret".to_string(), + expires_at: expires_at.to_string(), + api_base: "https://postil.dev/api/inference/v1".to_string(), + org: "runatlas-is".to_string(), + model: "z-ai/glm-5.2".to_string(), + } + } + + #[test] + fn round_trips_through_a_temp_directory() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("postil").join("credentials.json"); + let credentials = sample("2999-01-01T00:00:00.000Z"); + write(&path, &credentials).unwrap(); + let read_back = read(&path).unwrap().expect("credentials were written"); + assert_eq!(read_back, credentials); + } + + #[test] + fn read_of_a_missing_file_is_none_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("postil").join("credentials.json"); + assert!(read(&path).unwrap().is_none()); + } + + #[test] + fn remove_of_a_missing_file_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("postil").join("credentials.json"); + remove(&path).unwrap(); + } + + #[test] + fn remove_deletes_a_written_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("postil").join("credentials.json"); + write(&path, &sample("2999-01-01T00:00:00.000Z")).unwrap(); + assert!(path.exists()); + remove(&path).unwrap(); + assert!(!path.exists()); + } + + #[test] + #[cfg(unix)] + fn written_file_has_owner_only_permission_bits() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("postil").join("credentials.json"); + write(&path, &sample("2999-01-01T00:00:00.000Z")).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "credentials file must be mode 0600, got {mode:o}" + ); + } + + #[test] + #[cfg(unix)] + fn created_directory_has_owner_only_permission_bits() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let credentials_dir = dir.path().join("postil"); + let path = credentials_dir.join("credentials.json"); + write(&path, &sample("2999-01-01T00:00:00.000Z")).unwrap(); + let mode = fs::metadata(&credentials_dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o700, + "credentials directory must be mode 0700, got {mode:o}" + ); + } + + #[test] + fn future_expiry_is_not_expired() { + assert!(!sample("2999-01-01T00:00:00.000Z").is_expired()); + } + + #[test] + fn past_expiry_is_expired() { + assert!(sample("2020-01-01T00:00:00.000Z").is_expired()); + } + + #[test] + fn unparsable_expiry_fails_closed_as_expired() { + assert!(sample("not-a-timestamp").is_expired()); + } +} diff --git a/src/doctor.rs b/src/doctor.rs index 875b9fe..cdd1fc0 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -7,6 +7,7 @@ use crate::api_key; use crate::config::Config; +use crate::credentials; use crate::llm::LlmClient; use anyhow::Result; @@ -47,15 +48,46 @@ pub async fn run(cfg: &Config) -> Result> { }, }); - let key = api_key::resolve_from_process_env(); + let credentials_path = credentials::default_path(); + let stored_login = credentials_path + .as_ref() + .ok() + .and_then(|path| credentials::read(path).ok().flatten()); + checks.push(Check { + name: "login", + ok: stored_login.as_ref().is_none_or(|c| !c.is_expired()), + detail: match &stored_login { + None => "not logged in; `postil login` gives zero-config hosted inference".to_string(), + Some(c) if c.is_expired() => format!( + "credential for org {} expired at {}; run `postil login` again", + c.org, c.expires_at + ), + Some(c) => format!("logged in to org {} (expires {})", c.org, c.expires_at), + }, + }); + let key_names = api_key::names_text(); + let key_lookup = credentials_path + .as_ref() + .map_err(|e| anyhow::anyhow!("{e:#}")) + .and_then(|path| api_key::resolve_effective(path)); + let (key_ok, key_detail, key) = match key_lookup { + Ok(Some(k)) => ( + true, + format!("resolved from {key_names}, or a stored login credential (value not shown)"), + Some(k), + ), + Ok(None) => ( + false, + format!("set {key_names}, or run `postil login`; Postil never proxies inference"), + None, + ), + Err(e) => (false, format!("{e:#}"), None), + }; checks.push(Check { name: "api key", - ok: key.is_some(), - detail: match &key { - Some(_) => format!("{key_names} is set (value not shown)"), - None => format!("set {key_names}; Postil never proxies inference"), - }, + ok: key_ok, + detail: key_detail, }); // Live probe: a 1-token response proves base URL + key + model + selected diff --git a/src/lib.rs b/src/lib.rs index 47b698f..e46315e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod attribution; pub(crate) mod brevity; pub mod cli; pub mod config; +pub(crate) mod credentials; pub mod diff; pub mod doctor; pub(crate) mod durable_plan; @@ -15,6 +16,7 @@ pub mod forge; pub mod hook; pub mod llm; pub mod local; +pub mod login; pub mod output; pub mod plan; pub mod prompt; diff --git a/src/llm.rs b/src/llm.rs index ad60014..7e7163c 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -4400,12 +4400,23 @@ impl LlmTimeouts { } fn resolve_api_key() -> Result { - api_key::resolve_from_process_env().ok_or_else(|| { - let key_names = api_key::names_text(); - anyhow!( - "no API key: set {key_names}. Postil never proxies your inference; bring your own key." - ) - }) + // Checked first, and independently of the credentials-path lookup below, + // so an explicit key still works in a minimal environment with no HOME + // or XDG_CONFIG_HOME -- exactly what worked before login existed. + if let Some(key) = api_key::resolve_from_process_env() { + return Ok(key); + } + let credentials_path = crate::credentials::default_path() + .context("resolving the postil login credentials path")?; + match api_key::resolve_effective(&credentials_path)? { + Some(key) => Ok(key), + None => { + let key_names = api_key::names_text(); + Err(anyhow!( + "no API key: set {key_names}, or run `postil login`. Postil never proxies your inference without one of these." + )) + } + } } fn duration_from_env(name: &str, default_secs: Option) -> Result> { @@ -4470,7 +4481,7 @@ struct AnthropicUsage { output_tokens: Option, } -fn secure_http_client(api_base: &str) -> Result { +pub(crate) fn secure_http_client(api_base: &str) -> Result { let (hostname, addresses) = resolve_api_endpoint(api_base)?; reqwest::Client::builder() // A system proxy resolves the destination itself and would bypass the diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..e9af441 --- /dev/null +++ b/src/login.rs @@ -0,0 +1,436 @@ +//! `postil login` / `postil logout`: device-authorization flow against the +//! Postil web app, modelled on RFC 8628. Chosen because the CLI runs over +//! SSH and in containers where no localhost browser callback is reachable. +//! +//! A successful login writes a credential the API-key resolver +//! (`api_key::resolve_effective`) falls back to when no explicit key env var +//! is set, so a first-time user gets working hosted inference with zero +//! configuration. + +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; + +use crate::credentials::{self, Credentials}; +use crate::llm::secure_http_client; + +/// Overrides the Postil web app the device-flow calls target. Distinct from +/// `POSTIL_API_BASE`, which (once a token is in hand) points at the +/// inference gateway itself, not the auth endpoints. +const LOGIN_SERVER_ENV: &str = "POSTIL_LOGIN_SERVER"; +const DEFAULT_LOGIN_SERVER: &str = "https://postil.dev"; + +/// The server caps polling at 200 attempts and then returns 410 regardless; +/// this is only a client-side backstop against a stuck loop if that cap is +/// ever missed (a server bug, or a hostile `interval`). +const MAX_POLL_ATTEMPTS: u32 = 220; + +fn login_server() -> String { + std::env::var(LOGIN_SERVER_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_LOGIN_SERVER.to_string()) +} + +pub async fn run_login(org: Option) -> Result { + let server = login_server(); + let client = secure_http_client(&server).context("building the postil login HTTP client")?; + let path = credentials::default_path()?; + login_with(&client, &server, org.as_deref(), &path).await +} + +pub async fn run_logout() -> Result { + let server = login_server(); + let client = secure_http_client(&server).context("building the postil logout HTTP client")?; + let path = credentials::default_path()?; + logout_with(&client, &server, &path).await +} + +#[derive(Debug, Deserialize)] +struct DeviceStartResponse { + #[serde(rename = "deviceCode")] + device_code: String, + #[serde(rename = "userCode")] + user_code: String, + #[serde(rename = "verificationUri")] + verification_uri: String, + #[serde(rename = "verificationUriComplete")] + verification_uri_complete: String, + interval: u64, +} + +#[derive(Debug, Deserialize)] +struct DeviceTokenApproved { + token: String, + #[serde(rename = "expiresAt")] + expires_at: String, + #[serde(rename = "apiBase")] + api_base: String, + org: DeviceTokenOrg, + model: String, +} + +#[derive(Debug, Deserialize)] +struct DeviceTokenOrg { + slug: String, + name: String, +} + +async fn login_with( + client: &reqwest::Client, + server: &str, + org: Option<&str>, + credentials_path: &Path, +) -> Result { + let mut start_body = serde_json::json!({ "clientVersion": env!("CARGO_PKG_VERSION") }); + // The device/start contract takes no org field today; sending it as an + // extra, ignorable JSON key is a forward-compatible hint only. Org + // membership is actually chosen on the browser approval page, so the + // hint below is what carries the user's `--org` intent there. + if let Some(org) = org { + start_body["org"] = serde_json::Value::String(org.to_string()); + } + let start_url = format!("{}/api/cli/device/start", server.trim_end_matches('/')); + let start_response = client + .post(&start_url) + .json(&start_body) + .send() + .await + .context("starting postil login")?; + anyhow::ensure!( + start_response.status().is_success(), + "postil login could not start (server responded {})", + start_response.status() + ); + let start: DeviceStartResponse = start_response + .json() + .await + .context("parsing postil login start response")?; + + eprintln!("postil: open {}", start.verification_uri_complete); + eprintln!( + "postil: or open {} and enter code {}", + start.verification_uri, start.user_code + ); + if let Some(org) = org { + eprintln!("postil: select organization {org} when prompted"); + } + eprintln!("postil: waiting for approval..."); + + let interval = Duration::from_secs(interval_secs(start.interval)); + let token_url = format!("{}/api/cli/device/token", server.trim_end_matches('/')); + for _ in 0..MAX_POLL_ATTEMPTS { + tokio::time::sleep(interval).await; + let response = client + .post(&token_url) + .json(&serde_json::json!({ "deviceCode": start.device_code })) + .send() + .await + .context("polling postil login status")?; + match response.status().as_u16() { + 200 => { + let approved: DeviceTokenApproved = response + .json() + .await + .context("parsing postil login approval response")?; + let creds = Credentials { + version: credentials::CREDENTIALS_VERSION, + token: approved.token, + expires_at: approved.expires_at, + api_base: approved.api_base, + org: approved.org.slug, + model: approved.model, + }; + credentials::write(credentials_path, &creds)?; + eprintln!( + "postil: logged in to {} ({}) -- credential expires {}", + approved.org.name, creds.org, creds.expires_at + ); + return Ok(0); + } + 428 => continue, + 403 => { + eprintln!("postil: login request was denied"); + return Ok(1); + } + 410 => { + eprintln!("postil: login code expired; run `postil login` again"); + return Ok(1); + } + other => { + return Err(anyhow!("postil login failed: server responded {other}")); + } + } + } + eprintln!("postil: login timed out waiting for approval; run `postil login` again"); + Ok(1) +} + +/// Servers are the source of truth for pacing; this only guards against a +/// pathological `interval` (zero, or implausibly long) in a buggy or +/// hostile response. +fn interval_secs(server_interval: u64) -> u64 { + server_interval.clamp(1, 30) +} + +async fn logout_with( + client: &reqwest::Client, + server: &str, + credentials_path: &Path, +) -> Result { + match credentials::read(credentials_path) { + Ok(Some(creds)) => { + let logout_url = format!("{}/api/cli/logout", server.trim_end_matches('/')); + match client + .post(&logout_url) + .bearer_auth(&creds.token) + .send() + .await + { + Ok(response) if response.status().is_success() => {} + Ok(response) => eprintln!( + "postil: logout request returned {}; removing the local credential anyway", + response.status() + ), + Err(_) => eprintln!( + "postil: could not reach postil to revoke the credential; removing the local credential anyway" + ), + } + } + Ok(None) => eprintln!("postil: not logged in"), + Err(_) => eprintln!("postil: stored credentials were unreadable; removing them anyway"), + } + // Removal happens regardless of what happened above: a developer running + // `postil logout` wants the local secret gone even if the network is down. + credentials::remove(credentials_path)?; + eprintln!("postil: logged out"); + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + /// Returns the i-th `(status, body)` pair on each successive request, + /// repeating the last one past the end. Mirrors the codebase's existing + /// `SequentialReviewResponder` (see `tests/e2e.rs`) so a poll loop can be + /// driven through pending -> approved deterministically, rather than + /// relying on wiremock's mount-order/priority tie-breaking across + /// otherwise-identical requests. + #[derive(Clone)] + struct SequentialTokenResponder { + calls: Arc, + responses: Arc>, + } + + impl Respond for SequentialTokenResponder { + fn respond(&self, _request: &Request) -> ResponseTemplate { + let index = self.calls.fetch_add(1, Ordering::SeqCst); + let (status, body) = self + .responses + .get(index) + .or_else(|| self.responses.last()) + .cloned() + .expect("sequential responder requires at least one response"); + ResponseTemplate::new(status).set_body_json(body) + } + } + + fn approved_body() -> serde_json::Value { + serde_json::json!({ + "status": "approved", + "token": "pcli_test-token-not-a-real-secret", + "expiresAt": "2999-01-01T00:00:00.000Z", + "apiBase": "https://postil.dev/api/inference/v1", + "org": {"slug": "runatlas-is", "name": "RunAtlas"}, + "model": "z-ai/glm-5.2", + }) + } + + fn start_body() -> serde_json::Value { + serde_json::json!({ + "deviceCode": "test-device-code", + "userCode": "WDJF-3K9Q", + "verificationUri": "https://postil.dev/cli/authorize", + "verificationUriComplete": "https://postil.dev/cli/authorize?code=WDJF-3K9Q", + "expiresIn": 600, + "interval": 1, + }) + } + + #[tokio::test] + async fn login_writes_credentials_on_immediate_approval() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(approved_body())) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let exit = login_with( + &reqwest::Client::new(), + &server.uri(), + None, + &credentials_path, + ) + .await + .unwrap(); + assert_eq!(exit, 0); + let stored = credentials::read(&credentials_path).unwrap().unwrap(); + assert_eq!(stored.token, "pcli_test-token-not-a-real-secret"); + assert_eq!(stored.org, "runatlas-is"); + assert_eq!(stored.model, "z-ai/glm-5.2"); + } + + #[tokio::test] + async fn login_polls_through_pending_before_approval() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + let responder = SequentialTokenResponder { + calls: Arc::new(AtomicUsize::new(0)), + responses: Arc::new(vec![ + (428, serde_json::json!({"status": "pending"})), + (428, serde_json::json!({"status": "pending"})), + (200, approved_body()), + ]), + }; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with(responder.clone()) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let exit = login_with( + &reqwest::Client::new(), + &server.uri(), + None, + &credentials_path, + ) + .await + .unwrap(); + assert_eq!(exit, 0); + assert_eq!(responder.calls.load(Ordering::SeqCst), 3); + assert!(credentials::read(&credentials_path).unwrap().is_some()); + } + + #[tokio::test] + async fn login_reports_denial_and_writes_nothing() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with( + ResponseTemplate::new(403).set_body_json(serde_json::json!({"status": "denied"})), + ) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let exit = login_with( + &reqwest::Client::new(), + &server.uri(), + None, + &credentials_path, + ) + .await + .unwrap(); + assert_eq!(exit, 1); + assert!(credentials::read(&credentials_path).unwrap().is_none()); + } + + #[tokio::test] + async fn login_reports_expiry_and_writes_nothing() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with( + ResponseTemplate::new(410).set_body_json(serde_json::json!({"status": "expired"})), + ) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let exit = login_with( + &reqwest::Client::new(), + &server.uri(), + None, + &credentials_path, + ) + .await + .unwrap(); + assert_eq!(exit, 1); + assert!(credentials::read(&credentials_path).unwrap().is_none()); + } + + #[tokio::test] + async fn logout_removes_credentials_even_when_the_server_call_fails() { + // No mock mounted for /api/cli/logout at all: every request to this + // server 404s, standing in for "the network call failed." + let server = MockServer::start().await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write( + &credentials_path, + &Credentials { + version: credentials::CREDENTIALS_VERSION, + token: "pcli_test-token-not-a-real-secret".to_string(), + expires_at: "2999-01-01T00:00:00.000Z".to_string(), + api_base: "https://postil.dev/api/inference/v1".to_string(), + org: "runatlas-is".to_string(), + model: "z-ai/glm-5.2".to_string(), + }, + ) + .unwrap(); + + let exit = logout_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .unwrap(); + assert_eq!(exit, 0); + assert!(!credentials_path.exists()); + } + + #[tokio::test] + async fn logout_is_a_no_op_success_when_never_logged_in() { + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + + let exit = logout_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .unwrap(); + assert_eq!(exit, 0); + assert!(!credentials_path.exists()); + } +} diff --git a/src/main.rs b/src/main.rs index 132cefd..f17db4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use postil_cli::attribution; use postil_cli::cli::{Cli, Command, ForgeArg, HookAction, publication_enabled}; use postil_cli::config::{Config, qualification_metadata, starter_config}; use postil_cli::review::{ForgeKind, ReviewArgs}; -use postil_cli::{doctor, hook, plan, respond, review}; +use postil_cli::{doctor, hook, login, plan, respond, review}; #[tokio::main] async fn main() { @@ -212,5 +212,7 @@ async fn dispatch(cli: Cli) -> anyhow::Result { Ok(0) } }, + Command::Login { org } => login::run_login(org).await, + Command::Logout => login::run_logout().await, } }