diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index fd462a58..4b9949a9 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -46,6 +46,9 @@ jobs: os: macos-latest # - target: x86_64-apple-darwin # os: macos-latest + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + pam: true steps: - uses: actions/checkout@v6 @@ -57,6 +60,10 @@ jobs: if: runner.os == 'macOS' run: brew install protobuf + - name: Install libpam dev headers (PAM build) + if: ${{ matrix.pam }} + run: sudo apt-get update && sudo apt-get install -y libpam0g-dev + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -73,3 +80,9 @@ jobs: # re-saving), but the restore still refreshes the eviction timer. - name: Build release dependencies run: cargo build --release --workspace --bins --target ${{ matrix.target }} + + # Warm the PAM-only dependency (pam-sys) so the release run's PAM rebuild + # restores from cache too. + - name: Build PAM dependencies + if: ${{ matrix.pam }} + run: cargo build --release -p agentd-core --features pam --target ${{ matrix.target }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13dd35b6..b398cace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,44 @@ jobs: - name: Run Docker integration tests (orchestrator) run: cargo test -p orchestrator -- --ignored --test-threads=1 + pam-feature: + name: PAM feature build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v6 + + - name: Install protoc and libpam (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libpam0g-dev + + - name: Install protoc (macOS) + if: runner.os == 'macOS' + run: brew install protobuf + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + key: pam-feature-${{ matrix.os }} + + # The `pam` feature links the system PAM library and builds on both + # Linux (Linux-PAM) and macOS (OpenPAM). Build + clippy keep the + # feature-gated code compiling on each. The verifier's runtime behavior + # needs a live PAM stack and is exercised by the `--ignored` pam_smoke + # tests (see docs/pam-authentication.md). + - name: Build agentd-core with PAM + run: cargo build -p agentd-core --features pam + + - name: Clippy agentd-core with PAM + run: cargo clippy -p agentd-core --features pam --all-targets -- -D warnings + audit: name: Security Audit runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60e8238f..bbd31de1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,15 @@ jobs: # (1h+ builds) that cross-compiling beats building natively. # - target: x86_64-apple-darwin # os: macos-latest + # Dynamically-linked glibc build with PAM (system-user login) compiled + # in, shipped as a separate `-pam` tarball. PAM `dlopen`s its modules + # at runtime, which the static musl targets above cannot do — so a PAM + # artifact must be a glibc build. The default install stays musl/no-PAM; + # this is opt-in (install.sh AGENTD_PAM=1). glibc reduces portability: + # it requires a glibc at least as new as the runner's. + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + pam: true steps: - uses: actions/checkout@v6 with: @@ -64,6 +73,11 @@ jobs: if: runner.os == 'macOS' run: brew install protobuf + # The `pam` feature links libpam, so the dev headers must be present. + - name: Install libpam dev headers (PAM build) + if: ${{ matrix.pam }} + run: sudo apt-get update && sudo apt-get install -y libpam0g-dev + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -81,6 +95,13 @@ jobs: - name: Build release binaries run: cargo build --release --workspace --bins --target ${{ matrix.target }} + # Opt-in: overwrite agentd-core with a PAM-enabled build. Done as a + # separate `-p agentd-core` build (not a workspace `--features`) since the + # `pam` feature only exists on that crate — mirrors `cargo xtask`. + - name: Rebuild agentd-core with PAM support + if: ${{ matrix.pam }} + run: cargo build --release -p agentd-core --features pam --target ${{ matrix.target }} + - name: Download UI assets uses: actions/download-artifact@v4 with: @@ -98,6 +119,9 @@ jobs: set -euo pipefail TARGET="${{ matrix.target }}" VERSION="${{ github.ref_name }}" + # PAM builds ship under a distinct `-pam` suffix so they sit alongside + # the default (musl/no-PAM) artifact rather than replacing it. + SUFFIX="${{ matrix.pam && '-pam' || '' }}" BINS=( agentd-ask agentd-communicate @@ -119,12 +143,12 @@ jobs: cp "target/${TARGET}/release/cli" "stage/agent" chmod 755 stage/agent stage/agentd-* mkdir -p dist - tar -czf "dist/agentd-${VERSION}-${TARGET}.tar.gz" -C stage . + tar -czf "dist/agentd-${VERSION}-${TARGET}${SUFFIX}.tar.gz" -C stage . - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: tarball-${{ matrix.target }} + name: tarball-${{ matrix.target }}${{ matrix.pam && '-pam' || '' }} path: dist/*.tar.gz if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index e9d670d1..18e362aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,11 @@ dependencies = [ "futures-util", "http-body-util", "hyper", + "libc", "metrics", "metrics-exporter-prometheus", "notify", + "pam-sys", "rand 0.8.5", "reqwest", "sea-orm", @@ -4827,6 +4829,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "pam-sys" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4858311a097f01a0006ef7d0cd50bca81ec430c949d7bf95cbefd202282434" +dependencies = [ + "libc", +] + [[package]] name = "parking" version = "2.2.1" diff --git a/Makefile b/Makefile index 545a846f..253b4f84 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,25 @@ # make test Run all tests # make docker-build-claude Build the Claude Code Docker image locally -.PHONY: help build test clippy fmt fmt-fix docker-build-claude docker-build-claude-multiarch docker-run-claude +.PHONY: help build test clippy fmt fmt-fix \ + build-release build-ui \ + install-user install-user-pam install-system install-system-pam \ + uninstall-user uninstall-system \ + docker-build-claude docker-build-claude-multiarch docker-run-claude # Default image name — matches the DEFAULT_IMAGE constant in crates/wrap/src/docker.rs CLAUDE_IMAGE ?= agentd-claude:latest +# Set PAM=1 to compile agentd-core with system-user (PAM) login support. +# macOS needs no extra packages; on Linux install the PAM dev headers first +# (libpam0g-dev on Debian/Ubuntu, pam-devel on RHEL/Fedora). +PAM ?= 0 + +# Freshly-built CLI binary. It is the workspace bin `cli`; the installer renames +# it to `agent` on install. The installed `agent` (on PATH) is used to uninstall. +CLI_BIN := target/release/cli +UI_DIST := ui/dist + help: ## Show this help message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' @@ -32,6 +46,51 @@ fmt: ## Check formatting fmt-fix: ## Auto-fix formatting cargo fmt --all +# ── Install ────────────────────────────────────────────────────────── +# +# Four supported flows (all build from source, then run `agent install`): +# +# make install-user # per-user install (intended for macOS dev) +# make install-user-pam # … with PAM login +# make install-system # system-wide install (intended for Linux) +# make install-system-pam # … with PAM login +# +# PAM=1 also works on the base targets, e.g. `make install-user PAM=1`. +# +# PAM caveats: +# - Linux needs the PAM dev headers at build time (see PAM var above). +# - A Linux system install is what lets core verify other users' passwords; +# for real PAM auth its service account must also be in the `shadow` group +# (or use an SSSD stack). See docs/pam-authentication.md. + +build-release: ## Build release binaries (PAM=1 compiles agentd-core with PAM) + cargo build --release --workspace --bins + @if [ "$(PAM)" = "1" ]; then \ + echo "==> Rebuilding agentd-core with PAM support (libpam dev headers required on Linux)"; \ + cargo build --release -p agentd-core --features pam; \ + fi + +build-ui: ## Build the web UI assets (bun) + cd ui && bun install --frozen-lockfile && bun run build + +install-user: build-release build-ui ## Per-user install (macOS dev); PAM=1 for system-user login + $(CLI_BIN) install --user --bin-src target/release --ui-dir $(UI_DIST) + +install-system: build-release build-ui ## System-wide install (Linux); PAM=1 for system-user login + $(CLI_BIN) install --system --bin-src target/release --ui-dir $(UI_DIST) + +install-user-pam: ## Per-user install with PAM (macOS dev) + $(MAKE) install-user PAM=1 + +install-system-pam: ## System-wide install with PAM (Linux) + $(MAKE) install-system PAM=1 + +uninstall-user: ## Remove a per-user install (uses the installed `agent`) + agent uninstall --user + +uninstall-system: ## Remove a system-wide install (uses the installed `agent`) + agent uninstall --system + # ── Docker ─────────────────────────────────────────────────────────── docker-build-claude: ## Build the Claude Code agent Docker image locally diff --git a/contrib/scripts/install.sh b/contrib/scripts/install.sh index ba61fe98..e57d34ce 100755 --- a/contrib/scripts/install.sh +++ b/contrib/scripts/install.sh @@ -13,6 +13,11 @@ # Environment variables: # AGENTD_VERSION Install a specific version (e.g. "v0.5.0" or "0.5.0") # instead of the latest release. +# AGENTD_PAM When set to 1/true/yes/on, install the PAM-enabled build +# (system-user login). Only available as a prebuilt artifact +# for Linux x86_64 (a dynamically-linked glibc tarball — the +# static musl default cannot load PAM modules). On any other +# platform, build from source with `--features pam` instead. # PREFIX Install prefix honoured by `agent install` # (default: /usr/local on macOS or as root, ~/.local otherwise). # @@ -26,6 +31,15 @@ REPO="geoffjay/agentd" info() { printf '\033[0;34m==>\033[0m %s\n' "$1"; } error() { printf '\033[0;31merror:\033[0m %s\n' "$1" >&2; exit 1; } +# Truthy test for opt-in flags, matching the AGENTD_PAM semantics used by the +# Rust installer and config loader (1/true/yes/on, case-insensitive). +is_truthy() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + # Map uname output to a release target triple. detect_target() { os=$(uname -s) @@ -109,7 +123,23 @@ verify_checksum() { main() { target=$(detect_target) version=$(resolve_version) - asset="agentd-${version}-${target}.tar.gz" + + # Opt-in PAM build. Only Linux x86_64 ships a prebuilt PAM artifact: it is a + # dynamically-linked glibc tarball (`-pam` suffix, target + # x86_64-unknown-linux-gnu), because the static musl default cannot dlopen + # PAM modules. Any other platform must build from source. + suffix="" + if is_truthy "${AGENTD_PAM:-}"; then + if [ "$target" = "x86_64-unknown-linux-musl" ]; then + target="x86_64-unknown-linux-gnu" + suffix="-pam" + else + error "AGENTD_PAM is only available as a prebuilt artifact for Linux x86_64 (glibc). +For this platform ($(uname -s)/$(uname -m)), build from source with: cargo build -p agentd-core --features pam" + fi + fi + + asset="agentd-${version}-${target}${suffix}.tar.gz" base="https://github.com/$REPO/releases/download/$version" # Explicit template: unlike `mktemp -d` bare, this honours $TMPDIR on diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs index 312cd5fc..73e3a1ee 100644 --- a/crates/cli/src/commands/config.rs +++ b/crates/cli/src/commands/config.rs @@ -331,7 +331,6 @@ monitor_url = "http://localhost:17003" memory_url = "http://localhost:17008" communicate_url = "http://localhost:17010" knowledge_url = "http://localhost:17011" -index_url = "http://localhost:17012" # --------------------------------------------------------------------------- # [services.mcp] — MCP server (no dedicated port — uses stdio transport) diff --git a/crates/cli/src/commands/memory.rs b/crates/cli/src/commands/memory.rs index aa87c8d8..56fd75ac 100644 --- a/crates/cli/src/commands/memory.rs +++ b/crates/cli/src/commands/memory.rs @@ -511,8 +511,9 @@ async fn list( ])); for mem in &response.items { - let content_preview = if mem.content.len() > 50 { - format!("{}…", &mem.content[..49]) + let content_preview = if mem.content.chars().count() > 50 { + let truncated: String = mem.content.chars().take(49).collect(); + format!("{truncated}…") } else { mem.content.clone() }; diff --git a/crates/cli/src/commands/orchestrator.rs b/crates/cli/src/commands/orchestrator.rs index bcb5f89f..bbbb37f4 100644 --- a/crates/cli/src/commands/orchestrator.rs +++ b/crates/cli/src/commands/orchestrator.rs @@ -974,7 +974,7 @@ impl OrchestratorCommand { } _ => id.clone(), }; - stream_agents(resolved_id.as_deref(), *all, *verbose, json).await + stream_agents(client, resolved_id.as_deref(), *all, *verbose, json).await } OrchestratorCommand::GetPolicy { id } => get_policy(client, id, json).await, OrchestratorCommand::SetPolicy { id, policy } => { @@ -1328,7 +1328,7 @@ async fn create_agent( if is_pty { println!(); - attach_pty_agent(&agent).await?; + attach_pty_agent(client, &agent).await?; } else { let session = agent .session_id @@ -1584,7 +1584,7 @@ async fn attach_agent( // orchestrator process. Binary frames carry raw I/O bytes; JSON text // frames carry resize events: {"type":"resize","cols":N,"rows":N}. // Ctrl-D or Ctrl-] detaches without terminating the agent. - attach_pty_agent(&agent).await?; + attach_pty_agent(client, &agent).await?; } else { // Tmux backend: attach to the tmux session if std::process::Command::new("tmux") @@ -1633,6 +1633,24 @@ async fn attach_agent( // -- PTY terminal relay -- +/// Build a WebSocket URL for an orchestrator endpoint that routes through the +/// same core gateway as the client's REST calls. +/// +/// Rewrites the client's base URL scheme (`http`→`ws`, `https`→`wss`) and +/// appends `path`. The gateway authenticates WebSocket handshakes from a +/// `token` query parameter (browsers cannot set `Authorization` on an upgrade), +/// so the client's bearer token is appended when present. +fn orchestrator_ws_url(client: &OrchestratorClient, path: &str) -> String { + let ws_base = + client.base_url().replacen("https://", "wss://", 1).replacen("http://", "ws://", 1); + let mut url = format!("{ws_base}{path}"); + if let Some(token) = client.token() { + let sep = if url.contains('?') { '&' } else { '?' }; + url.push_str(&format!("{sep}token={}", urlencoding::encode(token))); + } + url +} + /// Connect to the orchestrator's WebSocket terminal relay for a PTY-backed agent. /// /// Enables crossterm raw mode, bridges local stdin/stdout with the remote PTY @@ -1645,11 +1663,8 @@ async fn attach_agent( /// `{"type":"resize","cols":N,"rows":N}`. /// - Detach keys: Ctrl-D (`\x04`) or Ctrl-`]` (`\x1d`) — detach without /// killing the agent. -async fn attach_pty_agent(agent: &AgentResponse) -> Result<()> { - let base_url = std::env::var("AGENTD_ORCHESTRATOR_SERVICE_URL") - .unwrap_or_else(|_| "http://localhost:7006".to_string()); - let ws_base = base_url.replace("http://", "ws://").replace("https://", "wss://"); - let ws_url = format!("{}/terminal/{}", ws_base, agent.id); +async fn attach_pty_agent(client: &OrchestratorClient, agent: &AgentResponse) -> Result<()> { + let ws_url = orchestrator_ws_url(client, &format!("/terminal/{}", agent.id)); println!("{}", format!("Attaching to agent '{}' via PTY terminal relay...", agent.name).cyan()); @@ -1858,19 +1873,22 @@ async fn send_message_cmd( // -- Stream -- -async fn stream_agents(id: Option<&str>, all: bool, verbose: bool, json: bool) -> Result<()> { +async fn stream_agents( + client: &OrchestratorClient, + id: Option<&str>, + all: bool, + verbose: bool, + json: bool, +) -> Result<()> { if id.is_none() && !all { bail!("Either an agent ID or --all must be provided."); } - let base_url = std::env::var("AGENTD_ORCHESTRATOR_SERVICE_URL") - .unwrap_or_else(|_| "http://localhost:7006".to_string()); - let ws_base = base_url.replace("http://", "ws://").replace("https://", "wss://"); - - let ws_url = match id { - Some(agent_id) => format!("{}/stream/{}", ws_base, agent_id), - None => format!("{}/stream", ws_base), + let path = match id { + Some(agent_id) => format!("/stream/{agent_id}"), + None => "/stream".to_string(), }; + let ws_url = orchestrator_ws_url(client, &path); if !json { let target = id.map(|a| format!("agent {}", a)).unwrap_or_else(|| "all agents".to_string()); @@ -3060,8 +3078,11 @@ fn display_workflow(workflow: &WorkflowResponse) { } } let template = &workflow.prompt_template; - let display = - if template.len() > 60 { format!("{}...", &template[..57]) } else { template.clone() }; + let display = if template.chars().count() > 60 { + format!("{}...", template.chars().take(57).collect::()) + } else { + template.clone() + }; println!("{}: {}", "Prompt Template".bold(), display); println!("{}: {}", "Created".bold(), workflow.created_at); } @@ -3080,7 +3101,11 @@ fn display_dispatch(dispatch: &DispatchResponse) { }; println!("{}: {}", "Status".bold(), colored_status); let prompt = &dispatch.prompt_sent; - let display = if prompt.len() > 60 { format!("{}...", &prompt[..57]) } else { prompt.clone() }; + let display = if prompt.chars().count() > 60 { + format!("{}...", prompt.chars().take(57).collect::()) + } else { + prompt.clone() + }; println!("{}: {}", "Prompt".bold(), display); println!("{}: {}", "Dispatched".bold(), dispatch.dispatched_at); if let Some(completed) = &dispatch.completed_at { @@ -3093,6 +3118,34 @@ mod tests { use super::*; use orchestrator::types::AgentConfig; + #[test] + fn ws_url_routes_through_gateway_base_with_token() { + let client = OrchestratorClient::new("http://localhost:17000/api/v1/orchestrator") + .with_token("tok-123"); + assert_eq!( + orchestrator_ws_url(&client, "/stream"), + "ws://localhost:17000/api/v1/orchestrator/stream?token=tok-123" + ); + } + + #[test] + fn ws_url_https_converts_to_wss() { + let client = OrchestratorClient::new("https://agentd.example.com/api/v1/orchestrator"); + assert_eq!( + orchestrator_ws_url(&client, "/terminal/abc"), + "wss://agentd.example.com/api/v1/orchestrator/terminal/abc" + ); + } + + #[test] + fn ws_url_without_token_has_no_query() { + let client = OrchestratorClient::new("http://localhost:17000/api/v1/orchestrator"); + assert_eq!( + orchestrator_ws_url(&client, "/stream/xyz"), + "ws://localhost:17000/api/v1/orchestrator/stream/xyz" + ); + } + #[test] fn test_display_agent_typed() { use chrono::Utc; diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index c262a48f..a7986f4d 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -45,6 +45,8 @@ //! | `AGENTD_MEMORY_PORT` | `services.memory.port` | //! | `AGENTD_MEMORY_EMBEDDING_PROVIDER` | `services.memory.embedding_provider` | //! | `AGENTD_MEMORY_EMBEDDING_MODEL` | `services.memory.embedding_model` | +//! | `AGENTD_MEMORY_EMBEDDING_API_KEY` | `services.memory.embedding_api_key` | +//! | `AGENTD_MEMORY_EMBEDDING_ENDPOINT` | `services.memory.embedding_endpoint` | //! | `AGENTD_MEMORY_LANCE_PATH` | `services.memory.lance_path` | //! | `AGENTD_HOOK_PORT` | `services.hook.port` | //! | `AGENTD_HISTORY_SIZE` | `services.hook.history_size` | @@ -236,6 +238,18 @@ pub struct MemoryConfig { pub embedding_provider: String, /// Embedding model name. Defaults to `"text-embedding-3-small"`. pub embedding_model: String, + /// API key for remote embedding providers (sent as a `Bearer` token). + /// + /// Omit for Ollama or other localhost providers. The + /// `AGENTD_MEMORY_EMBEDDING_API_KEY` env var overrides this when set. + #[serde(skip_serializing_if = "Option::is_none")] + pub embedding_api_key: Option, + /// Override the embedding provider's API base URL (e.g. an Ollama URL). + /// + /// Defaults to the provider's own default when unset. The + /// `AGENTD_MEMORY_EMBEDDING_ENDPOINT` env var overrides this when set. + #[serde(skip_serializing_if = "Option::is_none")] + pub embedding_endpoint: Option, /// LanceDB directory path. Defaults to XDG data dir. pub lance_path: String, } @@ -246,6 +260,8 @@ impl Default for MemoryConfig { port: 17008, embedding_provider: "none".to_string(), embedding_model: "text-embedding-3-small".to_string(), + embedding_api_key: None, + embedding_endpoint: None, lance_path: default_memory_lance_path(), } } @@ -383,8 +399,8 @@ pub struct CoreConfig { pub communicate_url: String, /// Upstream URL for the knowledge service. Defaults to `"http://localhost:17011"`. pub knowledge_url: String, - /// Upstream URL for the index service. Defaults to `"http://localhost:17012"`. - pub index_url: String, + /// PAM (system-user) authentication settings. See [`CorePamConfig`]. + pub pam: CorePamConfig, } impl Default for CoreConfig { @@ -400,7 +416,37 @@ impl Default for CoreConfig { memory_url: "http://localhost:17008".to_string(), communicate_url: "http://localhost:17010".to_string(), knowledge_url: "http://localhost:17011".to_string(), - index_url: "http://localhost:17012".to_string(), + pam: CorePamConfig::default(), + } + } +} + +/// PAM (system-user) authentication settings for `agentd-core`, nested under +/// `[services.core.pam]`. +/// +/// These mirror the `AGENTD_PAM_*` environment variables, which **override** +/// these values when set (see [`crate::config`] precedence). PAM login also +/// requires a build with `--features pam`; without it, `pam` logins fail closed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct CorePamConfig { + /// Master switch (`AGENTD_PAM_ENABLED`). When `false`, `pam` users fail + /// closed and just-in-time provisioning never triggers. Defaults to `false`. + pub enabled: bool, + /// PAM service name (`AGENTD_PAM_SERVICE`) → `/etc/pam.d/`. + /// Defaults to `"agentd"`. + pub service: String, + /// Domain for the synthesized email of a just-in-time provisioned user + /// (`AGENTD_PAM_EMAIL_DOMAIN`): `@`. Defaults to `"pam.local"`. + pub email_domain: String, +} + +impl Default for CorePamConfig { + fn default() -> Self { + Self { + enabled: false, + service: "agentd".to_string(), + email_domain: "pam.local".to_string(), } } } @@ -724,7 +770,6 @@ impl ValidateConfig for CoreConfig { ("core.memory_url", self.memory_url.as_str()), ("core.communicate_url", self.communicate_url.as_str()), ("core.knowledge_url", self.knowledge_url.as_str()), - ("core.index_url", self.index_url.as_str()), ] { validate_url(url, name)?; } @@ -946,6 +991,16 @@ fn merge(base: AgentdConfig, file: AgentdConfig) -> AgentdConfig { &file.services.memory.embedding_model, &d.services.memory.embedding_model, ), + embedding_api_key: file + .services + .memory + .embedding_api_key + .or(base.services.memory.embedding_api_key), + embedding_endpoint: file + .services + .memory + .embedding_endpoint + .or(base.services.memory.embedding_endpoint), lance_path: pick( &base.services.memory.lance_path, &file.services.memory.lance_path, @@ -1090,11 +1145,23 @@ fn merge(base: AgentdConfig, file: AgentdConfig) -> AgentdConfig { &file.services.core.knowledge_url, &d.services.core.knowledge_url, ), - index_url: pick( - &base.services.core.index_url, - &file.services.core.index_url, - &d.services.core.index_url, - ), + pam: CorePamConfig { + enabled: pick_bool( + base.services.core.pam.enabled, + file.services.core.pam.enabled, + d.services.core.pam.enabled, + ), + service: pick( + &base.services.core.pam.service, + &file.services.core.pam.service, + &d.services.core.pam.service, + ), + email_domain: pick( + &base.services.core.pam.email_domain, + &file.services.core.pam.email_domain, + &d.services.core.pam.email_domain, + ), + }, }, mcp: McpConfig { orchestrator_url: pick( @@ -1230,6 +1297,12 @@ fn apply_env_overrides(cfg: &mut AgentdConfig) { if let Ok(v) = env::var("AGENTD_MEMORY_EMBEDDING_MODEL") { cfg.services.memory.embedding_model = v; } + if let Ok(v) = env::var("AGENTD_MEMORY_EMBEDDING_API_KEY") { + cfg.services.memory.embedding_api_key = Some(v); + } + if let Ok(v) = env::var("AGENTD_MEMORY_EMBEDDING_ENDPOINT") { + cfg.services.memory.embedding_endpoint = Some(v); + } if let Ok(v) = env::var("AGENTD_MEMORY_LANCE_PATH") { cfg.services.memory.lance_path = v; } @@ -1359,6 +1432,15 @@ fn pick_u16(base: u16, file: u16, default: u16) -> u16 { } } +#[inline] +fn pick_bool(base: bool, file: bool, default: bool) -> bool { + if file != default { + file + } else { + base + } +} + #[inline] fn pick_u64(base: u64, file: u64, default: u64) -> u64 { if file != default { @@ -1629,6 +1711,55 @@ history_size = 1000 assert_eq!(cfg.services.ask.port, 17001); } + #[test] + fn test_memory_embedding_credentials_from_file() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + env::remove_var("AGENTD_MEMORY_EMBEDDING_API_KEY"); + env::remove_var("AGENTD_MEMORY_EMBEDDING_ENDPOINT"); + let mut f = tempfile::NamedTempFile::new().unwrap(); + writeln!( + f, + "[services.memory]\nembedding_provider = \"openai\"\nembedding_api_key = \"sk-from-file\"\nembedding_endpoint = \"http://localhost:11434/v1\"" + ) + .unwrap(); + + let cfg = load_from_path(Some(f.path())).expect("load failed"); + + assert_eq!(cfg.services.memory.embedding_provider, "openai"); + assert_eq!(cfg.services.memory.embedding_api_key.as_deref(), Some("sk-from-file")); + assert_eq!( + cfg.services.memory.embedding_endpoint.as_deref(), + Some("http://localhost:11434/v1") + ); + } + + #[test] + fn test_memory_embedding_credentials_env_beats_file() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut f = tempfile::NamedTempFile::new().unwrap(); + writeln!( + f, + "[services.memory]\nembedding_api_key = \"sk-from-file\"\nembedding_endpoint = \"http://from-file/v1\"" + ) + .unwrap(); + env::set_var("AGENTD_MEMORY_EMBEDDING_API_KEY", "sk-from-env"); + env::set_var("AGENTD_MEMORY_EMBEDDING_ENDPOINT", "http://from-env/v1"); + + let cfg = load_from_path(Some(f.path())).expect("load failed"); + env::remove_var("AGENTD_MEMORY_EMBEDDING_API_KEY"); + env::remove_var("AGENTD_MEMORY_EMBEDDING_ENDPOINT"); + + assert_eq!(cfg.services.memory.embedding_api_key.as_deref(), Some("sk-from-env")); + assert_eq!(cfg.services.memory.embedding_endpoint.as_deref(), Some("http://from-env/v1")); + } + + #[test] + fn test_memory_embedding_credentials_default_none() { + let cfg = AgentdConfig::default(); + assert!(cfg.services.memory.embedding_api_key.is_none()); + assert!(cfg.services.memory.embedding_endpoint.is_none()); + } + // ── core upstream URLs ───────────────────────────────────────────────── #[test] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 34523fb8..38d12ee0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -36,6 +36,19 @@ rand = "0.8" reqwest = { workspace = true, features = ["json", "stream"] } clap = { workspace = true } +# PAM (system-user) authentication backend. Off by default; enabling it links +# the system PAM library. Uses raw `pam-sys` bindings (no bindgen/libclang) plus +# a small hand-written conversation, so it builds and runs on both Linux +# (Linux-PAM) and macOS (OpenPAM). Linux needs `libpam0g-dev`/`pam-devel` at link +# time; macOS links the SDK's libpam with no extra packages. +[target.'cfg(unix)'.dependencies] +pam-sys = { version = "0.5", optional = true } +libc = { version = "0.2", optional = true } + +[features] +# Enable PAM-based login (`auth_provider = 'pam'`). +pam = ["dep:pam-sys", "dep:libc"] + [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index eb4103e1..c04355e9 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -31,15 +31,47 @@ pub mod gateway; pub mod organizations; pub mod users; +use std::sync::Arc; + use axum::{routing::get, Json, Router}; use serde_json::{json, Value}; -use crate::{proxy::ProxyConfig, storage::Storage}; +use crate::{ + pam_auth::{PamConfig, PasswordVerifier, UnavailableVerifier}, + proxy::ProxyConfig, + storage::Storage, +}; /// Shared application state threaded through all route handlers. #[derive(Clone)] pub struct AppState { pub storage: Storage, + /// PAM settings (disabled by default). + pub pam_config: PamConfig, + /// Verifier used for `auth_provider = 'pam'` logins. Injectable so tests can + /// substitute a mock without a real PAM stack. + pub pam_verifier: Arc, +} + +impl AppState { + /// State with PAM disabled and a fail-closed verifier — the default for + /// tests and non-PAM deployments. + pub fn new(storage: Storage) -> Self { + Self { + storage, + pam_config: PamConfig::disabled(), + pam_verifier: Arc::new(UnavailableVerifier), + } + } + + /// State with PAM configured from the `[services.core.pam]` config section + /// overlaid with `AGENTD_PAM_*` environment variables, and a verifier + /// matching the build (real libpam under `--features pam`). + pub fn with_pam_loaded(storage: Storage) -> Self { + let pam_config = PamConfig::load(); + let pam_verifier = pam_config.build_verifier(); + Self { storage, pam_config, pam_verifier } + } } /// Build the application router without a proxy (for testing or when proxy is disabled). @@ -82,7 +114,7 @@ mod tests { async fn test_app() -> Router { let (conn, _tmp) = create_test_connection().await; let storage = Storage::new(conn).await.unwrap(); - create_router(AppState { storage }) + create_router(AppState::new(storage)) } #[tokio::test] diff --git a/crates/core/src/api/admin.rs b/crates/core/src/api/admin.rs index c35d6337..e6894df3 100644 --- a/crates/core/src/api/admin.rs +++ b/crates/core/src/api/admin.rs @@ -202,7 +202,7 @@ mod tests { async fn setup() -> (Router, Storage, tempfile::TempDir) { let (conn, tmp) = create_test_connection().await; let storage = Storage::new(conn).await.unwrap(); - let router = create_router(AppState { storage: storage.clone() }); + let router = create_router(AppState::new(storage.clone())); (router, storage, tmp) } diff --git a/crates/core/src/api/auth.rs b/crates/core/src/api/auth.rs index f4c0dd84..62313c5c 100644 --- a/crates/core/src/api/auth.rs +++ b/crates/core/src/api/auth.rs @@ -27,6 +27,7 @@ use agentd_common::error::ApiError; use crate::{ entity::{organization, user}, middleware::auth::AuthUser, + pam_auth::PamError, session_storage::SessionStorage, user_storage::UserStorage, }; @@ -72,6 +73,8 @@ pub struct UserResponse { pub role: String, /// Product-level superuser flag — grants access to the `/admin` area. pub is_superuser: bool, + /// Authentication backend: `"local"` (password) or `"pam"` (system user). + pub auth_provider: String, pub active_organization_id: Option, pub created_at: String, pub updated_at: String, @@ -86,6 +89,7 @@ impl From for UserResponse { display_name: u.display_name, role: u.role, is_superuser: u.is_superuser, + auth_provider: u.auth_provider, active_organization_id: u.active_organization_id, created_at: u.created_at, updated_at: u.updated_at, @@ -246,51 +250,181 @@ async fn register_handler( /// `POST /auth/login` /// -/// Accepts `{ username, password }` or `{ email, password }`. Verifies the -/// password against the stored argon2 hash. Cleans up expired sessions for -/// the user, then issues a fresh session token. +/// Accepts `{ username, password }` or `{ email, password }`. The credential +/// check depends on the resolved user's `auth_provider`: /// +/// - `'local'` — verify against the stored argon2 hash (the default). +/// - `'pam'` — verify against the host PAM stack for `system_username`. +/// +/// When PAM is enabled and a login arrives by **username** for an account that +/// has no app-user row yet, the username is treated as a system account: if PAM +/// authenticates it, the app user is just-in-time provisioned (mirroring +/// registration) and login proceeds. +/// +/// On success, cleans up expired sessions and issues a fresh session token. /// Returns `200 OK` with `{ token, user, active_organization }`. async fn login_handler( State(state): State, Json(body): Json, ) -> Result { let users = state.storage.users(); - let sessions = state.storage.sessions(); - // Resolve user by username or email - let user = match (body.username.as_deref(), body.email.as_deref()) { - (Some(u), _) => users - .get_by_username(u) - .await - .map_err(ApiError::Internal)? - .ok_or_else(|| ApiError::Unauthorized("invalid credentials".into()))?, - (_, Some(e)) => users - .get_by_email(e) - .await - .map_err(ApiError::Internal)? - .ok_or_else(|| ApiError::Unauthorized("invalid credentials".into()))?, + // Resolve the identifier; remember whether it was a username (the JIT path + // uses the username as the system account name). + let (by_username, identifier) = match (body.username.as_deref(), body.email.as_deref()) { + (Some(u), _) => (true, u), + (_, Some(e)) => (false, e), _ => { return Err(ApiError::InvalidInput("one of `username` or `email` is required".into())); } }; - // Verify password - let ok = UserStorage::verify_password(&body.password, &user.password_hash) - .map_err(ApiError::Internal)?; - if !ok { - return Err(ApiError::Unauthorized("invalid credentials".into())); + // Look up the existing user without failing yet — a missing row may be a + // first-time PAM login to be provisioned below. + let existing = if by_username { + users.get_by_username(identifier).await.map_err(ApiError::Internal)? + } else { + users.get_by_email(identifier).await.map_err(ApiError::Internal)? + }; + + let user = match existing { + Some(u) => match u.auth_provider.as_str() { + "pam" => { + let system_username = u.system_username.as_deref().ok_or_else(|| { + ApiError::Internal(anyhow::anyhow!( + "pam user {} is missing system_username", + u.id + )) + })?; + authenticate_pam(&state, system_username, &body.password).await?; + u + } + // "local" and any unknown provider fall back to password auth. + _ => { + let ok = UserStorage::verify_password(&body.password, &u.password_hash) + .map_err(ApiError::Internal)?; + if !ok { + return Err(ApiError::Unauthorized("invalid credentials".into())); + } + u + } + }, + None => { + // No app-user row. The only way this becomes a successful login is a + // first-time PAM-by-username. Keep the error uniform with a bad + // password to avoid username enumeration. + if !state.pam_config.enabled || !by_username { + return Err(ApiError::Unauthorized("invalid credentials".into())); + } + authenticate_pam(&state, identifier, &body.password).await?; + provision_pam_user(&state, identifier).await? + } + }; + + issue_session(&state, user).await +} + +/// Verify a system user's password against the configured PAM stack, mapping +/// the outcome to an [`ApiError`]. Runs the blocking PAM FFI on a blocking +/// thread so the async runtime is not stalled. +async fn authenticate_pam( + state: &AppState, + system_username: &str, + password: &str, +) -> Result<(), ApiError> { + let verifier = state.pam_verifier.clone(); + let username = system_username.to_string(); + let password = password.to_string(); + + let result = + tokio::task::spawn_blocking(move || verifier.verify(&username, &password)).await.map_err( + |e| ApiError::Internal(anyhow::anyhow!("pam verification task failed: {e}")), + )?; + + match result { + Ok(()) => Ok(()), + // Credential / account-state rejections look identical to a bad password. + Err(PamError::AuthFailed) | Err(PamError::AccountInvalid) => { + Err(ApiError::Unauthorized("invalid credentials".into())) + } + // An unreachable/misconfigured stack is an operational fault, not a + // credential rejection — surface it as 500 and log it. + Err(PamError::Unavailable(e)) => { + tracing::error!("PAM stack unavailable during login for {system_username}: {e:#}"); + Err(ApiError::Internal(e)) + } } +} - // Clean up expired sessions for this user +/// Just-in-time provisioning for a first-time PAM login. Mirrors +/// [`register_handler`]: creates the user, a default personal organization, an +/// owner membership, and sets the org active. Reuses existing storage helpers. +/// +/// Concurrency: two simultaneous first-logins can both pass the existence +/// check. The unique constraints on `system_username` / `email` make the loser +/// fail; we then re-fetch and treat the row as already provisioned. +async fn provision_pam_user( + state: &AppState, + system_username: &str, +) -> Result { + let users = state.storage.users(); + + // Race guard: someone may have just provisioned this account. + if let Some(existing) = + users.get_by_system_username(system_username).await.map_err(ApiError::Internal)? + { + return Ok(existing); + } + + let email = format!("{}@{}", system_username, state.pam_config.email_domain); + + let user = match users.create_pam(system_username, &email).await { + Ok(u) => u, + // Lost a provisioning race (or a colliding email/username) — adopt the + // existing row rather than failing the login. + Err(_) => users + .get_by_system_username(system_username) + .await + .map_err(ApiError::Internal)? + .ok_or_else(|| { + ApiError::Internal(anyhow::anyhow!( + "failed to provision pam user {system_username}" + )) + })?, + }; + + // If we adopted an already-provisioned row, it already has an org. + if user.active_organization_id.is_some() { + return Ok(user); + } + + let orgs = state.storage.organizations(); + let memberships = state.storage.memberships(); + + let org_name = format!("{system_username}'s workspace"); + let org_slug = slugify(&format!("{system_username}-workspace")); + let org = orgs.create(&org_name, &org_slug).await.map_err(ApiError::Internal)?; + memberships.add_member(&user.id, &org.id, "owner").await.map_err(ApiError::Internal)?; + + let user = + users.set_active_organization(&user.id, Some(&org.id)).await.map_err(ApiError::Internal)?; + Ok(user) +} + +/// Shared post-authentication tail: clean up expired sessions, mint a new token, +/// and build the `LoginResponse`. Used by every login path (local, PAM, JIT). +async fn issue_session( + state: &AppState, + user: user::Model, +) -> Result, ApiError> { + let sessions = state.storage.sessions(); sessions.delete_expired_for_user(&user.id).await.map_err(ApiError::Internal)?; - // Issue a new session token let token = SessionStorage::generate_token(); let expires_at = session_expires_at(); sessions.create(&user.id, &token, &expires_at).await.map_err(ApiError::Internal)?; - let active_org = fetch_active_org(&state, user.active_organization_id.as_deref()).await?; + let active_org = fetch_active_org(state, user.active_organization_id.as_deref()).await?; Ok(Json(LoginResponse { token, @@ -358,10 +492,13 @@ mod tests { use http_body_util::BodyExt; use tower::ServiceExt; + use crate::pam_auth::{PamConfig, PamError, PasswordVerifier}; + use std::sync::Arc; + async fn test_app() -> (Router, tempfile::TempDir) { let (conn, tmp) = create_test_connection().await; let storage = crate::storage::Storage::new(conn).await.unwrap(); - let state = AppState { storage }; + let state = AppState::new(storage); let app = crate::api::create_router(state); (app, tmp) } @@ -371,6 +508,130 @@ mod tests { serde_json::from_slice(&bytes).unwrap() } + /// Scripted verifier standing in for a real PAM stack in tests. + struct MockPamVerifier { + result: fn() -> Result<(), PamError>, + } + impl PasswordVerifier for MockPamVerifier { + fn verify(&self, _username: &str, _password: &str) -> Result<(), PamError> { + (self.result)() + } + } + + /// Build a PAM-enabled app whose verifier returns `result()`. + async fn pam_app(result: fn() -> Result<(), PamError>) -> (Router, tempfile::TempDir) { + let (conn, tmp) = create_test_connection().await; + let storage = crate::storage::Storage::new(conn).await.unwrap(); + let mut state = AppState::new(storage); + state.pam_config = PamConfig { enabled: true, ..PamConfig::disabled() }; + state.pam_verifier = Arc::new(MockPamVerifier { result }); + (crate::api::create_router(state), tmp) + } + + async fn login(app: &Router, payload: serde_json::Value) -> axum::response::Response { + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap() + } + + // ----------------------------------------------------------------------- + // PAM login + JIT provisioning + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_pam_login_jit_provisions_user_and_org() { + let (app, _tmp) = pam_app(|| Ok(())).await; + + let response = + login(&app, serde_json::json!({ "username": "deploy", "password": "syspass" })).await; + + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert!(body["token"].as_str().is_some()); + assert_eq!(body["user"]["username"], "deploy"); + assert_eq!(body["user"]["auth_provider"], "pam"); + // JIT creates a personal org and sets it active. + assert!(body["active_organization"]["name"].as_str().is_some()); + assert!(body["user"]["active_organization_id"].as_str().is_some()); + } + + #[tokio::test] + async fn test_pam_login_is_idempotent_on_second_login() { + let (app, _tmp) = pam_app(|| Ok(())).await; + + let first = + login(&app, serde_json::json!({ "username": "deploy", "password": "syspass" })).await; + let first_id = body_json(first).await["user"]["id"].as_str().unwrap().to_string(); + + let second = + login(&app, serde_json::json!({ "username": "deploy", "password": "syspass" })).await; + assert_eq!(second.status(), StatusCode::OK); + let second_id = body_json(second).await["user"]["id"].as_str().unwrap().to_string(); + + // No duplicate user is provisioned on the second login. + assert_eq!(first_id, second_id); + } + + #[tokio::test] + async fn test_pam_login_rejected_returns_401() { + let (app, _tmp) = pam_app(|| Err(PamError::AuthFailed)).await; + + let response = + login(&app, serde_json::json!({ "username": "deploy", "password": "wrong" })).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_pam_stack_unavailable_returns_500() { + let (app, _tmp) = pam_app(|| Err(PamError::Unavailable(anyhow::anyhow!("no pam")))).await; + + let response = + login(&app, serde_json::json!({ "username": "deploy", "password": "syspass" })).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn test_pam_disabled_does_not_provision() { + // PAM compiled/available but operationally disabled → unknown user 401. + let (app, _tmp) = test_app().await; + let response = + login(&app, serde_json::json!({ "username": "deploy", "password": "syspass" })).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_pam_login_by_email_does_not_provision() { + // JIT only triggers by username (the system account name), never email. + let (app, _tmp) = pam_app(|| Ok(())).await; + let response = + login(&app, serde_json::json!({ "email": "deploy@pam.local", "password": "x" })).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_local_user_unaffected_when_pam_enabled() { + // A registered local user still authenticates by password even when PAM + // is enabled (the escape hatch stays intact). + let (app, _tmp) = pam_app(|| Err(PamError::AuthFailed)).await; + register_user(&app, "alice", "alice@example.com", "secret123").await; + + let ok = + login(&app, serde_json::json!({ "username": "alice", "password": "secret123" })).await; + assert_eq!(ok.status(), StatusCode::OK); + assert_eq!(body_json(ok).await["user"]["auth_provider"], "local"); + + let bad = login(&app, serde_json::json!({ "username": "alice", "password": "nope" })).await; + assert_eq!(bad.status(), StatusCode::UNAUTHORIZED); + } + // ----------------------------------------------------------------------- // Register // ----------------------------------------------------------------------- diff --git a/crates/core/src/api/organizations.rs b/crates/core/src/api/organizations.rs index d6794c86..a0865cf7 100644 --- a/crates/core/src/api/organizations.rs +++ b/crates/core/src/api/organizations.rs @@ -371,7 +371,7 @@ mod tests { async fn test_app() -> (Router, tempfile::TempDir) { let (conn, tmp) = create_test_connection().await; let storage = Storage::new(conn).await.unwrap(); - let state = AppState { storage }; + let state = AppState::new(storage); let app = crate::api::create_router(state); (app, tmp) } diff --git a/crates/core/src/api/users.rs b/crates/core/src/api/users.rs index 1402534a..201889cf 100644 --- a/crates/core/src/api/users.rs +++ b/crates/core/src/api/users.rs @@ -246,7 +246,7 @@ mod tests { async fn test_app() -> (Router, tempfile::TempDir) { let (conn, tmp) = create_test_connection().await; let storage = Storage::new(conn).await.unwrap(); - let state = AppState { storage }; + let state = AppState::new(storage); let app = crate::api::create_router(state); (app, tmp) } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 486491a9..c3e3edb1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -10,6 +10,22 @@ //! | `AGENTD_HOST` | `127.0.0.1` | HTTP bind host | //! | `AGENTD_CORE_PORT` | `17000` | HTTP listen port | //! | `AGENTD_PORT` | — | Fallback port (legacy) | +//! | `SESSION_EXPIRY_HOURS` | `24` | Session token lifetime | +//! +//! ## PAM (system-user) login +//! +//! Loaded by [`crate::pam_auth::PamConfig::load`] from the shared +//! `[services.core.pam]` config section, with the environment variables below +//! overlaid on top (env wins). Requires a build with `--features pam` and a +//! deployment where the core process can verify system passwords (system +//! service in the `shadow` group, or an SSSD-backed stack — see the PAM +//! deployment guide under `docs/`). +//! +//! | Env variable / `[services.core.pam]` key | Default | Description | +//! |------------------------------------------|---------|-------------| +//! | `AGENTD_PAM_ENABLED` / `enabled` | `false` | Master switch for PAM login | +//! | `AGENTD_PAM_SERVICE` / `service` | `agentd` | PAM service → `/etc/pam.d/` | +//! | `AGENTD_PAM_EMAIL_DOMAIN` / `email_domain` | `pam.local` | Domain for synthesized JIT-user emails | use agentd_common::config::ValidateConfig; use anyhow::{bail, Result}; diff --git a/crates/core/src/entity/organization.rs b/crates/core/src/entity/organization.rs index c6571c21..a94c970c 100644 --- a/crates/core/src/entity/organization.rs +++ b/crates/core/src/entity/organization.rs @@ -76,6 +76,8 @@ mod tests { display_name: Set(None), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), diff --git a/crates/core/src/entity/session.rs b/crates/core/src/entity/session.rs index fac01a1c..a9120aa2 100644 --- a/crates/core/src/entity/session.rs +++ b/crates/core/src/entity/session.rs @@ -68,6 +68,8 @@ mod tests { display_name: Set(None), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), diff --git a/crates/core/src/entity/user.rs b/crates/core/src/entity/user.rs index 96d63e1a..05b84e6f 100644 --- a/crates/core/src/entity/user.rs +++ b/crates/core/src/entity/user.rs @@ -17,6 +17,9 @@ pub struct Model { pub username: Option, #[sea_orm(unique)] pub email: String, + /// argon2id PHC hash for `'local'` users. For `'pam'` users this holds a + /// non-parseable sentinel (`"!"`) so [`crate::user_storage::UserStorage::verify_password`] + /// fails closed if ever called on them. pub password_hash: String, pub display_name: Option, /// Role string — `"admin"` or `"user"`. @@ -24,6 +27,13 @@ pub struct Model { /// Product-level superuser flag — grants access to the product admin area /// (`/admin`). Orthogonal to `role` and to organization membership roles. pub is_superuser: bool, + /// Authentication backend for this user: `"local"` (argon2 password, the + /// default) or `"pam"` (host PAM stack, verified against `system_username`). + pub auth_provider: String, + /// For `'pam'` users, the immutable OS account name authenticated against + /// (kept distinct from the editable app `username`). `None` for `'local'`. + #[sea_orm(unique)] + pub system_username: Option, /// The organization the user is currently operating as (nullable). pub active_organization_id: Option, pub created_at: String, @@ -96,6 +106,8 @@ mod tests { display_name: Set(Some("Alice".to_string())), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ed2d864b..6e948a80 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,6 +11,7 @@ pub mod membership_storage; pub mod middleware; pub mod migration; pub mod organization_storage; +pub mod pam_auth; pub mod proxy; pub mod session_storage; pub mod storage; diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 4dc8c6d6..06b5ebda 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -140,7 +140,7 @@ async fn run_serve() -> Result<()> { let metrics_router = axum::Router::new().route("/metrics", get(metrics_handler)).with_state(metrics_handle); - let state = agentd_core::api::AppState { storage }; + let state = agentd_core::api::AppState::with_pam_loaded(storage); // Build the gateway's upstream map from the shared `[services.core]` // config (with bare `*_URL` env vars still overriding for compatibility). diff --git a/crates/core/src/membership_storage.rs b/crates/core/src/membership_storage.rs index 8f0f9a63..6b41963a 100644 --- a/crates/core/src/membership_storage.rs +++ b/crates/core/src/membership_storage.rs @@ -170,6 +170,8 @@ mod tests { display_name: Set(None), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), diff --git a/crates/core/src/migration/m20260618_000004_add_auth_provider_to_users.rs b/crates/core/src/migration/m20260618_000004_add_auth_provider_to_users.rs new file mode 100644 index 00000000..217576a9 --- /dev/null +++ b/crates/core/src/migration/m20260618_000004_add_auth_provider_to_users.rs @@ -0,0 +1,81 @@ +//! Migration: add `auth_provider` and `system_username` columns to `users`. +//! +//! `auth_provider` selects the authentication backend for a user: `"local"` +//! (argon2 password, the default) or `"pam"` (host PAM stack). It is added as +//! `NOT NULL DEFAULT 'local'` so every existing row stays on password auth. +//! +//! `system_username` is the immutable OS account name a `'pam'` user is +//! authenticated against. It is nullable (only `'pam'` users set it) and unique +//! (one app user per system account). SQLite treats multiple NULLs as distinct, +//! so a plain unique index permits any number of `'local'` users. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Users::Table) + .add_column( + ColumnDef::new(Users::AuthProvider).string().not_null().default("local"), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Users::Table) + .add_column(ColumnDef::new(Users::SystemUsername).string().null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_users_system_username") + .table(Users::Table) + .col(Users::SystemUsername) + .unique() + .if_not_exists() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite does not reliably support DROP COLUMN; rebuild the table + // without the two new columns, preserving all others (matches the + // pattern used by migrations 2 and 3). + manager + .get_connection() + .execute_unprepared( + "CREATE TABLE users_backup AS SELECT id, username, email, password_hash, \ + display_name, role, is_superuser, active_organization_id, created_at, \ + updated_at FROM users", + ) + .await?; + manager.get_connection().execute_unprepared("DROP TABLE users").await?; + manager + .get_connection() + .execute_unprepared("ALTER TABLE users_backup RENAME TO users") + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Users { + Table, + AuthProvider, + SystemUsername, +} diff --git a/crates/core/src/migration/mod.rs b/crates/core/src/migration/mod.rs index b8ded89f..4a31b80b 100644 --- a/crates/core/src/migration/mod.rs +++ b/crates/core/src/migration/mod.rs @@ -14,6 +14,7 @@ pub use sea_orm_migration::prelude::*; mod m20260305_000001_create_core_tables; mod m20260408_000002_add_username_to_users; mod m20260614_000003_add_is_superuser_to_users; +mod m20260618_000004_add_auth_provider_to_users; /// The migration runner — applies all known migrations in order. pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { Box::new(m20260305_000001_create_core_tables::Migration), Box::new(m20260408_000002_add_username_to_users::Migration), Box::new(m20260614_000003_add_is_superuser_to_users::Migration), + Box::new(m20260618_000004_add_auth_provider_to_users::Migration), ] } } diff --git a/crates/core/src/pam_auth.rs b/crates/core/src/pam_auth.rs new file mode 100644 index 00000000..07dff651 --- /dev/null +++ b/crates/core/src/pam_auth.rs @@ -0,0 +1,502 @@ +//! PAM (Pluggable Authentication Modules) password verification for +//! system-user logins. +//! +//! This is the `'pam'` half of the pluggable auth backend: instead of verifying +//! a password against an argon2 hash in the database, a user with +//! `auth_provider = 'pam'` is authenticated against the host's PAM stack for the +//! configured service (default `agentd` → `/etc/pam.d/agentd`). The argon2 +//! `'local'` provider remains the default and the bootstrap/superuser escape +//! hatch. +//! +//! # Privilege requirement +//! +//! `pam_unix`'s SUID helper `unix_chkpwd` refuses to verify any account other +//! than the caller's own UID. So to authenticate *other* system users the +//! `agentd-core` process must be able to read `/etc/shadow` directly — i.e. run +//! as a **system** service whose account is in the `shadow` group (or use an +//! SSSD-backed stack, where `pam_sss` consults a privileged socket and needs no +//! local shadow access). See the deployment guide under `docs/`. +//! +//! # Build gating +//! +//! The real implementation links the system PAM library and is gated behind +//! `all(unix, feature = "pam")`, so it builds and runs on both Linux (Linux-PAM) +//! and macOS (OpenPAM). Every other configuration (CI without the feature, +//! Windows) compiles a [`UnavailableVerifier`] stub so the crate always builds +//! and a stray `auth_provider = 'pam'` row fails **closed** (500) rather than +//! ever mis-authenticating. + +use std::sync::Arc; + +/// PAM settings, loaded from the `[services.core.pam]` config section with any +/// `AGENTD_PAM_*` environment variables overlaid on top (env wins). +#[derive(Debug, Clone)] +pub struct PamConfig { + /// Master switch (`AGENTD_PAM_ENABLED` / `pam.enabled`). When `false`, + /// `'pam'` users fail closed and just-in-time provisioning never triggers. + pub enabled: bool, + /// PAM service name (`AGENTD_PAM_SERVICE` / `pam.service`, default `agentd`) + /// → `/etc/pam.d/`. + pub service: String, + /// Domain used to synthesize the (required, unique) email address of a + /// just-in-time provisioned PAM user (`AGENTD_PAM_EMAIL_DOMAIN` / + /// `pam.email_domain`, default `pam.local`): `@`. + pub email_domain: String, +} + +impl PamConfig { + /// Default configuration with PAM disabled. + pub fn disabled() -> Self { + Self { + enabled: false, + service: "agentd".to_string(), + email_domain: "pam.local".to_string(), + } + } + + /// Load PAM settings from the shared `[services.core.pam]` config section, + /// then overlay any `AGENTD_PAM_*` environment variables (env wins). + pub fn load() -> Self { + let file = agentd_common::config::load().map(|c| c.services.core.pam).unwrap_or_default(); + Self::from_file_and_env(file) + } + + /// Overlay `AGENTD_PAM_*` environment variables onto the file-derived + /// settings. An env var that is unset (or, for the string fields, blank) + /// leaves the corresponding config value untouched. + fn from_file_and_env(file: agentd_common::config::CorePamConfig) -> Self { + let enabled = std::env::var("AGENTD_PAM_ENABLED") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(file.enabled); + let service = std::env::var("AGENTD_PAM_SERVICE") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(file.service); + let email_domain = std::env::var("AGENTD_PAM_EMAIL_DOMAIN") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(file.email_domain); + + // Loudly warn if PAM is requested but this binary cannot honor it. + #[cfg(not(all(unix, feature = "pam")))] + if enabled { + tracing::warn!( + "PAM is enabled (AGENTD_PAM_ENABLED / [services.core.pam] enabled) but this \ + build lacks the `pam` feature; PAM logins will be rejected. Rebuild \ + agentd-core with `--features pam`." + ); + } + + Self { enabled, service, email_domain } + } + + /// Build the verifier matching this configuration. On a non-PAM build this + /// is always [`UnavailableVerifier`]. + pub fn build_verifier(&self) -> Arc { + build_verifier(&self.service) + } +} + +/// Outcome of a failed PAM verification. +#[derive(Debug)] +pub enum PamError { + /// Credentials rejected — wrong password or unknown user. Maps to 401. + AuthFailed, + /// Account exists but may not log in (expired, locked, password change + /// required, ...). Surfaced by `acct_mgmt`. Maps to 401. + AccountInvalid, + /// The PAM stack could not be consulted (misconfiguration, missing helper, + /// unreachable SSSD socket, feature not compiled in). Maps to 500. + Unavailable(anyhow::Error), +} + +/// Verifies a system user's password against the host authentication stack. +/// +/// Implementations must be cheap to share via [`Arc`] and safe to call from a +/// blocking thread (the HTTP handler invokes them inside +/// `tokio::task::spawn_blocking`, since the PAM FFI is blocking). +pub trait PasswordVerifier: Send + Sync { + /// Returns `Ok(())` when `password` authenticates `username`. + fn verify(&self, username: &str, password: &str) -> Result<(), PamError>; +} + +/// Fail-closed verifier used when PAM support is not compiled in (non-`pam` +/// build or non-unix target). Always reports the stack as unavailable so a +/// `'pam'` user can never be authenticated by accident. +pub struct UnavailableVerifier; + +impl PasswordVerifier for UnavailableVerifier { + fn verify(&self, _username: &str, _password: &str) -> Result<(), PamError> { + Err(PamError::Unavailable(anyhow::anyhow!( + "PAM support is not compiled into this build (rebuild with `--features pam`)" + ))) + } +} + +#[cfg(all(unix, feature = "pam"))] +mod imp { + //! Real PAM verifier built on raw `pam-sys` bindings plus a hand-written, + //! non-interactive conversation. Using raw bindings (rather than a + //! higher-level wrapper) keeps this building on both Linux-PAM and macOS + //! OpenPAM. The one true ABI disagreement between them is the *status-code + //! numbering*, which `pam-sys` hard-codes for Linux; we re-interpret the raw + //! integers per-platform in [`rc`]. The conversation message-array layout is + //! shared by both (array of pointers); only Solaris differs — see + //! [`message_at`]. + + use super::{PamError, PasswordVerifier}; + use std::ffi::{c_void, CString}; + use std::os::raw::{c_char, c_int}; + use std::ptr; + + use pam_sys::{ + acct_mgmt, authenticate, end, start, PamConversation, PamFlag, PamHandle, PamMessage, + PamMessageStyle, PamResponse, PamReturnCode, + }; + + /// Credentials passed to the conversation callback through `data_ptr`. + struct Credentials { + username: CString, + password: CString, + } + + pub struct PamVerifier { + service: String, + } + + impl PamVerifier { + pub fn new(service: impl Into) -> Self { + Self { service: service.into() } + } + } + + impl PasswordVerifier for PamVerifier { + fn verify(&self, username: &str, password: &str) -> Result<(), PamError> { + // NUL bytes can't appear in C strings; reject as a failed auth. + let creds = Credentials { + username: CString::new(username).map_err(|_| PamError::AuthFailed)?, + password: CString::new(password).map_err(|_| PamError::AuthFailed)?, + }; + + let conv = PamConversation { + conv: Some(converse), + data_ptr: &creds as *const Credentials as *mut c_void, + }; + + let mut handle: *mut PamHandle = ptr::null_mut(); + let start_rc = start(&self.service, Some(username), &conv, &mut handle); + if start_rc != PamReturnCode::SUCCESS || handle.is_null() { + return Err(PamError::Unavailable(anyhow::anyhow!( + "pam_start failed for service `{}`: {:?}", + self.service, + start_rc + ))); + } + + // SAFETY: `handle` is non-null per the check above and remains valid + // until `end`. `creds` outlives the whole transaction. + let handle_ref = unsafe { &mut *handle }; + + // `pam-sys` decodes the raw status with Linux-PAM's numbering, which + // is wrong on OpenPAM (macOS/BSD). The enum discriminant still equals + // the raw integer for in-range codes, so recover it with `as i32` and + // classify against platform-correct constants (see [`rc`]). + let auth_rc = authenticate(handle_ref, PamFlag::NONE); + let auth_code = auth_rc as i32; + let result = if auth_code == rc::SUCCESS { + let acct_code = acct_mgmt(handle_ref, PamFlag::NONE) as i32; + if acct_code == rc::SUCCESS { + Ok(()) + } else { + Err(classify_acct(acct_code)) + } + } else { + Err(classify_auth(auth_code)) + }; + + end(handle_ref, auth_rc); + drop(creds); + result + } + } + + /// Index the i-th conversation message. Linux-PAM **and** OpenPAM + /// (macOS/BSD) pass an array of message *pointers* — message `i` is + /// `msg[i]` (`*msg.offset(i)`), as OpenPAM's own `openpam_ttyconv` reads it. + /// Solaris/illumos instead pass a pointer to a single *contiguous* array + /// (`(*msg).offset(i)`); that is the real PAM portability split. We target + /// Linux and macOS, so the contiguous form is isolated behind a Solaris cfg + /// for any future port. (For a single-prompt conversation the two aliasing + /// at `i == 0` masks the difference; it only bites with multiple messages.) + #[cfg(not(any(target_os = "solaris", target_os = "illumos")))] + unsafe fn message_at(msg: *mut *mut PamMessage, i: isize) -> *const PamMessage { + *msg.offset(i) + } + #[cfg(any(target_os = "solaris", target_os = "illumos"))] + unsafe fn message_at(msg: *mut *mut PamMessage, i: isize) -> *const PamMessage { + (*msg).offset(i) + } + + /// Non-interactive conversation: answer echo-off prompts (password) with the + /// preset password and echo-on prompts (login) with the username; ignore + /// informational/error messages. The response array and each string are + /// allocated with libc so PAM can `free()` them. + extern "C" fn converse( + num_msg: c_int, + msg: *mut *mut PamMessage, + out_resp: *mut *mut PamResponse, + appdata: *mut c_void, + ) -> c_int { + if num_msg <= 0 || msg.is_null() || out_resp.is_null() || appdata.is_null() { + return PamReturnCode::CONV_ERR as c_int; + } + // SAFETY: `appdata` is the `&Credentials` we passed to `pam_start`, alive + // for the duration of the transaction. + let creds = unsafe { &*(appdata as *const Credentials) }; + + let n = num_msg as usize; + // SAFETY: zero-initialised array of `n` responses; PAM frees it. + let resp = + unsafe { libc::calloc(n, std::mem::size_of::()) as *mut PamResponse }; + if resp.is_null() { + return PamReturnCode::BUF_ERR as c_int; + } + + for i in 0..n { + // SAFETY: `i < n == num_msg`; `message_at` reads within the array. + let m = unsafe { message_at(msg, i as isize) }; + if m.is_null() { + continue; + } + let style = unsafe { (*m).msg_style }; + let reply: Option<&CString> = if style == PamMessageStyle::PROMPT_ECHO_OFF as c_int { + Some(&creds.password) + } else if style == PamMessageStyle::PROMPT_ECHO_ON as c_int { + Some(&creds.username) + } else { + None + }; + if let Some(value) = reply { + // SAFETY: strdup with libc so PAM owns and frees the copy. + let dup = unsafe { libc::strdup(value.as_ptr() as *const c_char) }; + unsafe { (*resp.add(i)).resp = dup }; + } + } + + // SAFETY: hand ownership of the response array to PAM. + unsafe { *out_resp = resp }; + PamReturnCode::SUCCESS as c_int + } + + /// Raw libpam status codes. `PAM_SUCCESS` is `0` on every platform, but the + /// *error* codes are numbered differently by Linux-PAM and OpenPAM + /// (macOS/BSD) — e.g. `PAM_USER_UNKNOWN` is 10 on Linux but 13 on OpenPAM, + /// where 13 is instead `PAM_ACCT_EXPIRED`. `pam-sys` hard-codes the Linux + /// numbering, so we re-interpret the raw integer here rather than trusting + /// its decoded symbol. Sources: `security/_pam_types.h` (Linux), + /// `security/pam_constants.h` (OpenPAM). + mod rc { + pub const SUCCESS: i32 = 0; + + #[cfg(target_os = "linux")] + mod platform { + pub const PERM_DENIED: i32 = 6; + pub const AUTH_ERR: i32 = 7; + pub const CRED_INSUFFICIENT: i32 = 8; + pub const USER_UNKNOWN: i32 = 10; + pub const MAXTRIES: i32 = 11; + pub const NEW_AUTHTOK_REQD: i32 = 12; + pub const ACCT_EXPIRED: i32 = 13; + pub const AUTHTOK_EXPIRED: i32 = 27; + } + + #[cfg(not(target_os = "linux"))] + mod platform { + pub const PERM_DENIED: i32 = 7; + pub const MAXTRIES: i32 = 8; + pub const AUTH_ERR: i32 = 9; + pub const NEW_AUTHTOK_REQD: i32 = 10; + pub const CRED_INSUFFICIENT: i32 = 11; + pub const USER_UNKNOWN: i32 = 13; + pub const ACCT_EXPIRED: i32 = 17; + pub const AUTHTOK_EXPIRED: i32 = 18; + } + + pub use platform::*; + } + + /// Human-readable name for a raw status code, for diagnostics. Falls back to + /// the bare integer for codes we do not specifically map. + fn describe(code: i32) -> String { + let name = match code { + rc::SUCCESS => "SUCCESS", + rc::PERM_DENIED => "PERM_DENIED", + rc::AUTH_ERR => "AUTH_ERR", + rc::CRED_INSUFFICIENT => "CRED_INSUFFICIENT", + rc::USER_UNKNOWN => "USER_UNKNOWN", + rc::MAXTRIES => "MAXTRIES", + rc::NEW_AUTHTOK_REQD => "NEW_AUTHTOK_REQD", + rc::ACCT_EXPIRED => "ACCT_EXPIRED", + rc::AUTHTOK_EXPIRED => "AUTHTOK_EXPIRED", + _ => return format!("code {code}"), + }; + format!("{name} ({code})") + } + + /// Map an `authenticate()` failure: anything that isn't a clear operational + /// fault is treated as a credential rejection. + fn classify_auth(code: i32) -> PamError { + match code { + rc::AUTH_ERR + | rc::USER_UNKNOWN + | rc::CRED_INSUFFICIENT + | rc::PERM_DENIED + | rc::MAXTRIES => PamError::AuthFailed, + other => PamError::Unavailable(anyhow::anyhow!( + "pam_authenticate failed: {}", + describe(other) + )), + } + } + + /// Map an `acct_mgmt()` failure: account-state problems are credential-level + /// rejections; everything else is operational. + fn classify_acct(code: i32) -> PamError { + match code { + rc::ACCT_EXPIRED + | rc::NEW_AUTHTOK_REQD + | rc::AUTHTOK_EXPIRED + | rc::USER_UNKNOWN + | rc::PERM_DENIED => PamError::AccountInvalid, + other => { + PamError::Unavailable(anyhow::anyhow!("pam_acct_mgmt failed: {}", describe(other))) + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn user_unknown_is_a_credential_rejection() { + // This is the code that surfaced mislabeled as "ACCT_EXPIRED" on + // macOS (raw 13). It must classify as a 401, not a 500. + assert!(matches!(classify_auth(rc::USER_UNKNOWN), PamError::AuthFailed)); + assert!(matches!(classify_auth(rc::AUTH_ERR), PamError::AuthFailed)); + } + + #[test] + fn operational_codes_are_unavailable() { + // SYSTEM_ERR (4) is the same on both platforms and is operational. + assert!(matches!(classify_auth(4), PamError::Unavailable(_))); + } + + #[test] + fn account_states_are_invalid() { + assert!(matches!(classify_acct(rc::ACCT_EXPIRED), PamError::AccountInvalid)); + assert!(matches!(classify_acct(rc::NEW_AUTHTOK_REQD), PamError::AccountInvalid)); + } + + #[test] + fn platform_user_unknown_matches_host_libpam() { + #[cfg(target_os = "linux")] + assert_eq!(rc::USER_UNKNOWN, 10); + #[cfg(not(target_os = "linux"))] + assert_eq!(rc::USER_UNKNOWN, 13); + } + } +} + +/// Build the real PAM verifier for the given service. +#[cfg(all(unix, feature = "pam"))] +pub fn build_verifier(service: &str) -> Arc { + Arc::new(imp::PamVerifier::new(service)) +} + +/// Non-PAM builds: always fail closed. +#[cfg(not(all(unix, feature = "pam")))] +pub fn build_verifier(_service: &str) -> Arc { + Arc::new(UnavailableVerifier) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unavailable_verifier_fails_closed() { + let v = UnavailableVerifier; + assert!(matches!(v.verify("root", "anything"), Err(PamError::Unavailable(_)))); + } + + #[test] + fn disabled_config_defaults() { + let cfg = PamConfig::disabled(); + assert!(!cfg.enabled); + assert_eq!(cfg.service, "agentd"); + assert_eq!(cfg.email_domain, "pam.local"); + } + + // Env-var mutation must be serialized: these tests share process env. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + const PAM_VARS: [&str; 3] = + ["AGENTD_PAM_ENABLED", "AGENTD_PAM_SERVICE", "AGENTD_PAM_EMAIL_DOMAIN"]; + + fn clear_pam_env() { + for v in PAM_VARS { + std::env::remove_var(v); + } + } + + #[test] + fn file_values_used_when_env_absent() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + clear_pam_env(); + let file = agentd_common::config::CorePamConfig { + enabled: true, + service: "chkpasswd".to_string(), + email_domain: "corp.example".to_string(), + }; + let cfg = PamConfig::from_file_and_env(file); + clear_pam_env(); + assert!(cfg.enabled); + assert_eq!(cfg.service, "chkpasswd"); + assert_eq!(cfg.email_domain, "corp.example"); + } + + #[test] + fn env_overrides_file() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + clear_pam_env(); + std::env::set_var("AGENTD_PAM_ENABLED", "false"); + std::env::set_var("AGENTD_PAM_SERVICE", "agentd"); + let file = agentd_common::config::CorePamConfig { + enabled: true, + service: "chkpasswd".to_string(), + email_domain: "corp.example".to_string(), + }; + let cfg = PamConfig::from_file_and_env(file); + clear_pam_env(); + // Env disables even though the file enabled PAM, and overrides the service. + assert!(!cfg.enabled); + assert_eq!(cfg.service, "agentd"); + // Untouched field falls through from the file. + assert_eq!(cfg.email_domain, "corp.example"); + } + + #[test] + fn blank_env_string_leaves_file_value() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + clear_pam_env(); + std::env::set_var("AGENTD_PAM_SERVICE", " "); + let file = agentd_common::config::CorePamConfig { + service: "chkpasswd".to_string(), + ..Default::default() + }; + let cfg = PamConfig::from_file_and_env(file); + clear_pam_env(); + assert_eq!(cfg.service, "chkpasswd"); + } +} diff --git a/crates/core/src/proxy.rs b/crates/core/src/proxy.rs index a6d0c4e6..ed00bd52 100644 --- a/crates/core/src/proxy.rs +++ b/crates/core/src/proxy.rs @@ -26,7 +26,6 @@ //! | monitor | `monitor_url` | `MONITOR_URL` | `http://localhost:17003` | //! | memory | `memory_url` | `MEMORY_URL` | `http://localhost:17008` | //! | communicate | `communicate_url` | `COMMUNICATE_URL` | `http://localhost:17010` | -//! | index | `index_url` | `INDEX_URL` | `http://localhost:17012` | //! | knowledge | `knowledge_url` | `KNOWLEDGE_URL` | `http://localhost:17011` | use std::collections::HashMap; @@ -63,7 +62,6 @@ impl ProxyConfig { ("monitor", "MONITOR_URL", core.monitor_url.as_str()), ("memory", "MEMORY_URL", core.memory_url.as_str()), ("communicate", "COMMUNICATE_URL", core.communicate_url.as_str()), - ("index", "INDEX_URL", core.index_url.as_str()), ("knowledge", "KNOWLEDGE_URL", core.knowledge_url.as_str()), ] .into_iter() @@ -237,8 +235,9 @@ mod tests { assert!(cfg.url_for("monitor").is_some()); assert!(cfg.url_for("memory").is_some()); assert!(cfg.url_for("communicate").is_some()); - assert!(cfg.url_for("index").is_some()); assert!(cfg.url_for("knowledge").is_some()); + // The index service was removed; it must not be a proxy target. + assert!(cfg.url_for("index").is_none()); assert!(cfg.url_for("nonexistent").is_none()); } @@ -247,7 +246,6 @@ mod tests { let cfg = ProxyConfig::from_env(); assert_eq!(cfg.url_for("memory"), Some("http://localhost:17008")); assert_eq!(cfg.url_for("communicate"), Some("http://localhost:17010")); - assert_eq!(cfg.url_for("index"), Some("http://localhost:17012")); assert_eq!(cfg.url_for("knowledge"), Some("http://localhost:17011")); } diff --git a/crates/core/src/session_storage.rs b/crates/core/src/session_storage.rs index b6d668f5..232ee0e0 100644 --- a/crates/core/src/session_storage.rs +++ b/crates/core/src/session_storage.rs @@ -148,6 +148,8 @@ mod tests { display_name: Set(None), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), diff --git a/crates/core/src/user_storage.rs b/crates/core/src/user_storage.rs index 1d81c4ec..f541bf91 100644 --- a/crates/core/src/user_storage.rs +++ b/crates/core/src/user_storage.rs @@ -61,6 +61,35 @@ impl UserStorage { display_name: Set(display_name.map(str::to_string)), role: Set(role.to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), + active_organization_id: Set(None), + created_at: Set(now.clone()), + updated_at: Set(now), + } + .insert(&self.db) + .await?; + Ok(model) + } + + /// Insert a new PAM-backed user linked to a system account. + /// + /// `username` is set to `system_username`, `auth_provider` is `"pam"`, and + /// `password_hash` is a non-parseable sentinel (`"!"`) so that + /// [`Self::verify_password`] can never accidentally authenticate the row. + /// The synthesized `email` must be unique (the column is `NOT NULL UNIQUE`). + pub async fn create_pam(&self, system_username: &str, email: &str) -> Result { + let now = chrono::Utc::now().to_rfc3339(); + let model = user::ActiveModel { + id: Set(Uuid::new_v4().to_string()), + username: Set(Some(system_username.to_string())), + email: Set(email.to_string()), + password_hash: Set("!".to_string()), + display_name: Set(Some(system_username.to_string())), + role: Set("user".to_string()), + is_superuser: Set(false), + auth_provider: Set("pam".to_string()), + system_username: Set(Some(system_username.to_string())), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), @@ -85,6 +114,17 @@ impl UserStorage { Ok(user::Entity::find().filter(Column::Email.eq(email)).one(&self.db).await?) } + /// Return the PAM user linked to the given system account, or `None`. + pub async fn get_by_system_username( + &self, + system_username: &str, + ) -> Result> { + Ok(user::Entity::find() + .filter(Column::SystemUsername.eq(system_username)) + .one(&self.db) + .await?) + } + /// Update mutable user fields. Fields set to `None` are left unchanged. /// /// Returns an error if the user does not exist. @@ -397,6 +437,40 @@ mod tests { assert_eq!(page2.items.len(), 2); } + #[tokio::test] + async fn test_create_sets_local_provider() { + let (storage, _tmp) = setup().await; + let user = + storage.create(Some("liz"), "liz@example.com", None, "pass", "user").await.unwrap(); + assert_eq!(user.auth_provider, "local"); + assert!(user.system_username.is_none()); + } + + #[tokio::test] + async fn test_create_pam_and_lookup_by_system_username() { + let (storage, _tmp) = setup().await; + let user = storage.create_pam("deploy", "deploy@pam.local").await.unwrap(); + assert_eq!(user.auth_provider, "pam"); + assert_eq!(user.system_username, Some("deploy".to_string())); + assert_eq!(user.username, Some("deploy".to_string())); + // Sentinel hash must never verify. + assert!( + !UserStorage::verify_password("deploy@pam.local", &user.password_hash).unwrap_or(false) + ); + + let found = storage.get_by_system_username("deploy").await.unwrap().unwrap(); + assert_eq!(found.id, user.id); + assert!(storage.get_by_system_username("nobody").await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_unique_system_username_constraint() { + let (storage, _tmp) = setup().await; + storage.create_pam("deploy", "deploy@pam.local").await.unwrap(); + let dup = storage.create_pam("deploy", "deploy2@pam.local").await; + assert!(dup.is_err(), "duplicate system_username should be rejected"); + } + #[tokio::test] async fn test_unique_email_constraint() { let (storage, _tmp) = setup().await; diff --git a/crates/core/tests/e2e_auth_flow.rs b/crates/core/tests/e2e_auth_flow.rs index f5f9b091..4c1742f5 100644 --- a/crates/core/tests/e2e_auth_flow.rs +++ b/crates/core/tests/e2e_auth_flow.rs @@ -73,7 +73,7 @@ async fn start_notify(tmp: &tempfile::TempDir) -> SocketAddr { async fn start_core(notify_addr: SocketAddr) -> (SocketAddr, tempfile::TempDir) { let (conn, tmp) = create_test_connection().await; let storage = Storage::new(conn).await.unwrap(); - let state = AppState { storage }; + let state = AppState::new(storage); let mut services: HashMap<&'static str, String> = HashMap::new(); services.insert("notify", format!("http://{notify_addr}")); diff --git a/crates/core/tests/entity_relations.rs b/crates/core/tests/entity_relations.rs index 7a09fd76..521d4768 100644 --- a/crates/core/tests/entity_relations.rs +++ b/crates/core/tests/entity_relations.rs @@ -38,6 +38,8 @@ async fn insert_user(db: &sea_orm::DatabaseConnection, email: &str) -> user::Mod display_name: Set(None), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), @@ -237,6 +239,8 @@ async fn test_unique_email_constraint() { display_name: Set(None), role: Set("user".to_string()), is_superuser: Set(false), + auth_provider: Set("local".to_string()), + system_username: Set(None), active_organization_id: Set(None), created_at: Set(now.clone()), updated_at: Set(now), diff --git a/crates/core/tests/pam_smoke.rs b/crates/core/tests/pam_smoke.rs new file mode 100644 index 00000000..263bc8c2 --- /dev/null +++ b/crates/core/tests/pam_smoke.rs @@ -0,0 +1,68 @@ +//! Runtime smoke tests for the real PAM verifier. +//! +//! These exercise the actual system PAM stack, so they are `#[ignore]` by +//! default and only built with `--features pam`. Run them manually on a host +//! with a PAM service configured: +//! +//! ```bash +//! # macOS: the built-in `chkpasswd` service uses pam_opendirectory. +//! cargo test -p agentd-core --features pam --test pam_smoke -- --ignored --nocapture +//! +//! # To also check a *successful* auth, supply your own password out-of-band: +//! AGENTD_PAM_TEST_USER=$USER AGENTD_PAM_TEST_PASSWORD='...' \ +//! cargo test -p agentd-core --features pam --test pam_smoke -- --ignored --nocapture +//! ``` +#![cfg(all(unix, feature = "pam"))] + +use agentd_core::pam_auth::{build_verifier, PamError}; + +/// Default PAM service to test against: macOS ships `chkpasswd` +/// (pam_opendirectory); Linux hosts should install `/etc/pam.d/agentd`. +fn service() -> String { + std::env::var("AGENTD_PAM_TEST_SERVICE").unwrap_or_else(|_| { + if cfg!(target_os = "macos") { + "chkpasswd".to_string() + } else { + "agentd".to_string() + } + }) +} + +fn current_user() -> String { + std::env::var("AGENTD_PAM_TEST_USER") + .or_else(|_| std::env::var("USER")) + .or_else(|_| std::env::var("LOGNAME")) + .expect("set USER or AGENTD_PAM_TEST_USER") +} + +/// A deliberately wrong password must be rejected — and, crucially, the whole +/// FFI round-trip (pam_start → conversation → pam_authenticate → pam_end) must +/// return cleanly rather than crash. This is the core cross-platform proof. +#[test] +#[ignore = "exercises the real system PAM stack"] +fn wrong_password_is_rejected() { + let verifier = build_verifier(&service()); + let user = current_user(); + let result = verifier.verify(&user, "definitely-not-the-password-xyzzy"); + // Must be a clean credential rejection (→ 401), not an operational + // `Unavailable` (→ 500). Before the OpenPAM return-code fix, macOS + // misclassified the rejection as `Unavailable` because `pam-sys` decodes + // raw statuses with Linux-PAM's numbering. + assert!( + matches!(result, Err(PamError::AuthFailed)), + "a bogus password must be rejected as AuthFailed, got: {result:?}" + ); +} + +/// Optional positive check — only runs when a real password is supplied. +#[test] +#[ignore = "requires AGENTD_PAM_TEST_PASSWORD"] +fn correct_password_authenticates() { + let Ok(password) = std::env::var("AGENTD_PAM_TEST_PASSWORD") else { + eprintln!("skipping: AGENTD_PAM_TEST_PASSWORD not set"); + return; + }; + let verifier = build_verifier(&service()); + let user = current_user(); + verifier.verify(&user, &password).expect("correct password should authenticate"); +} diff --git a/crates/install/src/install_config.rs b/crates/install/src/install_config.rs index ca045c25..53d2568f 100644 --- a/crates/install/src/install_config.rs +++ b/crates/install/src/install_config.rs @@ -82,6 +82,18 @@ fn build_install_defaults(services: &[ServiceInfo]) -> toml::Value { t.insert("backend".to_string(), Value::String("subprocess".to_string())); } + // memory: embedding provider/model + LanceDB path. These mirror the + // compiled defaults so the [services.memory] section is complete and + // discoverable after install. The merge is gap-fill, so user-set values + // are preserved. The API key/endpoint are intentionally omitted — they are + // optional and (for the key) secret, set via env or by editing config. + if let Some(Value::Table(t)) = services_map.get_mut("memory") { + let mem = agentd_common::config::MemoryConfig::default(); + t.insert("embedding_provider".to_string(), Value::String(mem.embedding_provider)); + t.insert("embedding_model".to_string(), Value::String(mem.embedding_model)); + t.insert("lance_path".to_string(), Value::String(mem.lance_path)); + } + // ask: orchestrator_url (compiled default points at dev port 17006) if let Some(Value::Table(t)) = services_map.get_mut("ask") { t.insert( @@ -227,6 +239,48 @@ mod tests { assert_eq!(svcs["orchestrator"]["backend"].as_str(), Some("subprocess")); } + #[test] + fn test_build_defaults_seeds_memory_embedding() { + let svcs = vec![ServiceInfo { + name: "memory", + binary: "agentd-memory", + port: 7008, + port_env: "AGENTD_MEMORY_PORT", + }]; + let defaults = build_install_defaults(&svcs); + let memory = &defaults["services"]["memory"]; + assert_eq!(memory["embedding_provider"].as_str(), Some("none")); + assert_eq!(memory["embedding_model"].as_str(), Some("text-embedding-3-small")); + assert!(memory["lance_path"].as_str().is_some_and(|p| !p.is_empty())); + // Secret/optional fields are not seeded. + assert!(memory.get("embedding_api_key").is_none()); + assert!(memory.get("embedding_endpoint").is_none()); + } + + #[test] + fn test_merge_preserves_user_embedding_provider() { + let mut base: toml::Value = toml::from_str( + r#" + [services.memory] + embedding_provider = "openai" + "#, + ) + .unwrap(); + + let svcs = vec![ServiceInfo { + name: "memory", + binary: "agentd-memory", + port: 7008, + port_env: "AGENTD_MEMORY_PORT", + }]; + merge_toml_defaults(&mut base, build_install_defaults(&svcs)); + + let memory = &base["services"]["memory"]; + // User-set provider preserved, model gap-filled. + assert_eq!(memory["embedding_provider"].as_str(), Some("openai")); + assert_eq!(memory["embedding_model"].as_str(), Some("text-embedding-3-small")); + } + #[test] fn test_merge_preserves_unrelated_sections() { let mut base: toml::Value = toml::from_str( diff --git a/crates/memory/src/config.rs b/crates/memory/src/config.rs index 83235e2d..6e9227fd 100644 --- a/crates/memory/src/config.rs +++ b/crates/memory/src/config.rs @@ -90,15 +90,18 @@ impl Default for EmbeddingConfig { impl EmbeddingConfig { /// Load configuration from the shared config file and environment variables. /// - /// Loads base values from [`agentd_common::config::load`], then overlays - /// legacy service-specific environment variables for backward compatibility. + /// Loads base values from [`agentd_common::config::load`] (which already + /// reflects the `[services.memory]` config section), then lets the + /// service-specific environment variables override them when set. + /// + /// All four settings are config-backed; the env var, when present, wins. /// - /// | Variable | Default | - /// |--------------------------------------|-------------------------------| - /// | `AGENTD_MEMORY_EMBEDDING_PROVIDER` | `"none"` | - /// | `AGENTD_MEMORY_EMBEDDING_MODEL` | `"text-embedding-3-small"` | - /// | `AGENTD_MEMORY_EMBEDDING_API_KEY` | `None` | - /// | `AGENTD_MEMORY_EMBEDDING_ENDPOINT` | `None` (uses provider default)| + /// | Variable | Config fallback | + /// |--------------------------------------|---------------------------------------| + /// | `AGENTD_MEMORY_EMBEDDING_PROVIDER` | `services.memory.embedding_provider` | + /// | `AGENTD_MEMORY_EMBEDDING_MODEL` | `services.memory.embedding_model` | + /// | `AGENTD_MEMORY_EMBEDDING_API_KEY` | `services.memory.embedding_api_key` | + /// | `AGENTD_MEMORY_EMBEDDING_ENDPOINT` | `services.memory.embedding_endpoint` | pub fn load() -> Self { let shared = agentd_common::config::load().unwrap_or_else(|e| { tracing::warn!("failed to load config file, using compiled defaults: {e:#}"); @@ -110,8 +113,8 @@ impl EmbeddingConfig { provider: env::var("AGENTD_MEMORY_EMBEDDING_PROVIDER") .unwrap_or(base.embedding_provider), model: env::var("AGENTD_MEMORY_EMBEDDING_MODEL").unwrap_or(base.embedding_model), - api_key: env::var("AGENTD_MEMORY_EMBEDDING_API_KEY").ok(), - base_url: env::var("AGENTD_MEMORY_EMBEDDING_ENDPOINT").ok(), + api_key: env::var("AGENTD_MEMORY_EMBEDDING_API_KEY").ok().or(base.embedding_api_key), + base_url: env::var("AGENTD_MEMORY_EMBEDDING_ENDPOINT").ok().or(base.embedding_endpoint), } } diff --git a/crates/orchestrator/src/client.rs b/crates/orchestrator/src/client.rs index 6ff69681..af4cea5e 100644 --- a/crates/orchestrator/src/client.rs +++ b/crates/orchestrator/src/client.rs @@ -83,6 +83,23 @@ impl OrchestratorClient { self } + /// The base URL this client targets (e.g. the core gateway + /// `{core_url}/api/v1/orchestrator`). + /// + /// Useful for deriving WebSocket URLs that must traverse the same gateway + /// and honor the same configuration as the REST calls. + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// The bearer token attached to this client, if any. + /// + /// WebSocket handshakes cannot carry an `Authorization` header through the + /// gateway, so callers pass this as a `token` query parameter instead. + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } + /// Create a client using the `AGENTD_ORCHESTRATOR_SERVICE_URL` environment /// variable, falling back to `http://localhost:7006`. /// diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 7303a693..9e7ef1d6 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -9,6 +9,7 @@ //! # Commands //! //! - `install-user` / `install` — Build everything and install for the current user +//! (set `AGENTD_PAM=1` to build `agentd-core` with PAM system-user login support) //! - `uninstall` — Remove all installed components //! - `start-services` / `stop-services` / `restart-services` — Service lifecycle //! - `start-service` / `stop-service` / `restart-service` — Single-service lifecycle @@ -84,6 +85,10 @@ fn print_help() { println!(); println!("{}", "Installation:".cyan()); println!(" {} - Build & install for current user", "install-user".green()); + println!( + " {} build agentd-core with PAM (system-user login) support", + "AGENTD_PAM=1".yellow() + ); println!(" {} - Generate & install shell completions", "install-completions".green()); println!(" {} - Uninstall all components", "uninstall".green()); println!(); @@ -597,6 +602,17 @@ fn check_in_project_root() -> Result<()> { Ok(()) } +/// Whether to build `agentd-core` with PAM (system-user login) support. +/// +/// Opt-in via `AGENTD_PAM=1` (also accepts `true`/`yes`/`on`). The `pam` feature +/// links the system PAM library and is off by default so standard installs don't +/// need it; this gate lets `install-user` produce a PAM-enabled build. +fn pam_enabled() -> bool { + std::env::var("AGENTD_PAM") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false) +} + fn build_release() -> Result<()> { let status = Command::new("cargo") .arg("build") @@ -610,6 +626,30 @@ fn build_release() -> Result<()> { anyhow::bail!("Build failed"); } + // Opt-in: rebuild agentd-core with PAM support. This second build overwrites + // target/release/agentd-core with the feature-enabled binary the installer + // then sources. Done as a separate `-p agentd-core` build (rather than a + // workspace `--features`) since the `pam` feature only exists on that crate. + if pam_enabled() { + println!("{}", "Rebuilding agentd-core with PAM support (AGENTD_PAM set)...".blue()); + let status = Command::new("cargo") + .arg("build") + .arg("--release") + .arg("-p") + .arg("agentd-core") + .arg("--features") + .arg("pam") + .status() + .context("Failed to execute PAM-enabled cargo build")?; + + if !status.success() { + anyhow::bail!( + "PAM-enabled build failed. On Linux, ensure the PAM dev library is installed \ + (libpam0g-dev on Debian/Ubuntu, pam-devel on RHEL/Fedora); macOS needs no extra packages." + ); + } + } + Ok(()) } diff --git a/docs/assets/pam/agentd b/docs/assets/pam/agentd new file mode 100644 index 00000000..38016dda --- /dev/null +++ b/docs/assets/pam/agentd @@ -0,0 +1,14 @@ +#%PAM-1.0 +# PAM service stack for agentd-core system-user login. +# +# Install as /etc/pam.d/agentd (matching AGENTD_PAM_SERVICE, default "agentd"). +# This Debian/Ubuntu stack delegates to the host's standard Unix auth via the +# `common-*` includes. On RHEL/Fedora, replace the includes with: +# auth substack system-auth +# account substack system-auth +# +# For an SSSD/LDAP/AD-backed host, point these at pam_sss instead of pam_unix — +# pam_sss consults a privileged socket and needs no local /etc/shadow access, +# so agentd-core does not need shadow-group membership in that configuration. +auth required pam_unix.so +account required pam_unix.so diff --git a/docs/pam-authentication.md b/docs/pam-authentication.md new file mode 100644 index 00000000..a485e10e --- /dev/null +++ b/docs/pam-authentication.md @@ -0,0 +1,196 @@ +# PAM (system-user) authentication + +`agentd-core` can authenticate logins against the host's **system users** via PAM +(Pluggable Authentication Modules), in addition to the default database password +(argon2) backend. This lets a person log in with their OS credentials — the same +identity that agents run under via `sudo -u `. + +PAM is a **pluggable, opt-in** backend: + +- Each user row has an `auth_provider`: `local` (argon2 password, the default and + the bootstrap/superuser/test escape hatch) or `pam` (host PAM stack). +- The first time a system user logs in by username, the app user is + **just-in-time provisioned** (a user record + a personal organization), linked + to the OS account via `system_username`. +- Local password auth is unaffected and remains available even when PAM is on. + +The backend works on both **macOS** (OpenPAM) and **Linux** (Linux-PAM) from the +same code. macOS is the easy path for local development; Linux production has an +extra privilege requirement (below). + +> [!CAUTION] +> On **Linux**, enabling PAM requires running `agentd-core` as a **system** +> service with permission to verify system passwords (see +> [Privilege model (Linux)](#privilege-model-linux)). That deployment-model +> change touches systemd unit privileges — a human-approval-adjacent area per +> `docs/planning/autonomous-pipeline-gates.md` — so the installer does **not** +> make it automatically. macOS dev has no such requirement. + +## Build + +The PAM backend links the system PAM library and is gated behind a Cargo +feature, off by default: + +```bash +# macOS: no extra packages — links the SDK's libpam. +# Debian/Ubuntu: sudo apt-get install -y libpam0g-dev +# RHEL/Fedora: sudo dnf install -y pam-devel +cargo build -p agentd-core --features pam --release +``` + +It uses raw `pam-sys` bindings plus a small hand-written conversation (no +bindgen/libclang). `pam-sys` hard-codes Linux-PAM's status-code numbering, so +the verifier re-interprets the raw `pam_authenticate`/`pam_acct_mgmt` return +codes against the correct per-platform constants (OpenPAM and Linux-PAM number +their error codes differently); without this a wrong password on macOS would be +misreported (e.g. as `ACCT_EXPIRED`) and surfaced as a `500` instead of a `401`. +The conversation message-array layout (an array of pointers) is shared by +Linux-PAM and OpenPAM; only Solaris/illumos differ there. + +A build **without** `--features pam` still runs, but every `pam` login fails +closed with `500` and a startup warning is logged if `AGENTD_PAM_ENABLED=true`. + +### Prebuilt artifact (Linux x86_64) + +The default release tarballs are static **musl** binaries and do **not** include +PAM (musl can't `dlopen` PAM modules). A separate dynamically-linked **glibc** +artifact ships with PAM compiled in for Linux x86_64 only; install it via the +opt-in flag: + +```bash +AGENTD_PAM=1 curl -fsSL https://github.com/geoffjay/agentd/releases/latest/download/install.sh | sh +``` + +This fetches `agentd--x86_64-unknown-linux-gnu-pam.tar.gz`. It requires +a glibc at least as new as the build runner's; on other platforms (arm64, macOS) +build from source with `--features pam` as above. + +## macOS (development) + +macOS needs no privilege setup. The system ships a ready-made `chkpasswd` PAM +service (backed by `pam_opendirectory`) that verifies a user's password without +root. Point agentd at it: + +```bash +AGENTD_PAM_ENABLED=true AGENTD_PAM_SERVICE=chkpasswd \ + cargo run -p agentd-core --features pam +``` + +Then log in with your macOS account username + password. Because the OpenPAM +round-trip is exercised the same way on macOS and Linux, the real verifier path +can be developed and tested entirely on a Mac — see the `pam_smoke` tests below. + +## Privilege model (Linux) + +> This section applies to Linux only. macOS verifies via `pam_opendirectory` +> with no shadow-group or root requirement (see [macOS](#macos-development)). + +`pam_unix` reads `/etc/shadow`. When the calling process cannot read shadow +directly it falls back to the SUID helper `unix_chkpwd`, which **refuses to +verify any account other than the caller's own UID** (an anti-brute-force +hardening). So an unprivileged / `systemd --user` core process **cannot** +authenticate other users. + +Pick one of: + +1. **shadow group + system service (recommended for self-hosted).** Run core as + a dedicated system service and add its account to the `shadow` group so + `pam_unix` reads `/etc/shadow` directly: + + ```bash + usermod -aG shadow agentd # core's service account + systemctl restart agentd-core + ``` + + Trade-off: `shadow` group membership grants read access to all password + hashes. A core compromise could exfiltrate hashes for offline cracking — but + not plaintext passwords and not root. This is a far smaller blast radius than + running core as root (which you should **not** do). + +2. **SSSD/LDAP/AD-backed PAM (recommended for enterprise/managed hosts).** Point + `/etc/pam.d/agentd` at `pam_sss`. `pam_sss` consults a privileged SSSD socket + and needs **no** local shadow access, so core requires neither shadow-group + membership nor root. Best security posture; requires SSSD infrastructure. + +## Install the PAM service file + +Install the sample stack ([`docs/assets/pam/agentd`](assets/pam/agentd)) as +`/etc/pam.d/` (default `agentd`): + +```bash +sudo install -m 0644 docs/assets/pam/agentd /etc/pam.d/agentd +``` + +Adjust the stack for your host (RHEL `system-auth`, or `pam_sss` for SSSD) per +the comments in that file. + +## Configuration + +PAM can be configured two ways. Settings are read from the shared `config.toml` +first, then any `AGENTD_PAM_*` environment variables are overlaid on top — **the +environment variable wins** when both are set. This lets you keep a stable base +in `config.toml` and override per-launch from the environment. + +### `config.toml` + +Add a `[services.core.pam]` section to the shared config file (on macOS, +`~/Library/Application Support/agentd-core/config.toml`; see the config guide for +your platform's path). This is the recommended way to run the **installed** +production binary, since it needs no environment wrangling at launch: + +```toml +[services.core.pam] +enabled = true +service = "chkpasswd" # macOS dev; "agentd" (the default) on Linux +email_domain = "pam.local" +``` + +### Environment variables + +| Variable / `[services.core.pam]` key | Default | Description | +|--------------------------------------|---------|-------------| +| `AGENTD_PAM_ENABLED` / `enabled` | `false` | Master switch. When off, `pam` users fail closed and JIT never triggers. | +| `AGENTD_PAM_SERVICE` / `service` | `agentd` | PAM service name → `/etc/pam.d/`. | +| `AGENTD_PAM_EMAIL_DOMAIN` / `email_domain` | `pam.local` | Domain for the synthesized email of a JIT-provisioned user (`@`). | + +## Manual verification + +These need a live PAM stack, so they run outside CI. + +### Verifier smoke tests (`--ignored`) + +The `pam_smoke` integration tests drive the real verifier. The wrong-password +case needs no credentials and runs anywhere with a PAM stack (including macOS): + +```bash +cargo test -p agentd-core --features pam --test pam_smoke -- --ignored --nocapture +# optional positive check (supply your own password out-of-band): +AGENTD_PAM_TEST_PASSWORD='...' \ + cargo test -p agentd-core --features pam --test pam_smoke -- --ignored --nocapture +``` + +### macOS end-to-end + +1. `AGENTD_PAM_ENABLED=true AGENTD_PAM_SERVICE=chkpasswd cargo run -p agentd-core --features pam` +2. `curl -sX POST localhost:17000/auth/login -H 'content-type: application/json' -d '{"username":"'"$USER"'","password":""}'` + → `200` + token, and a JIT-created user (`auth_provider: "pam"`) with a personal org. +3. Wrong password → `401`. A registered `local` user still logs in (escape hatch intact). + +### Linux production checklist + +1. Install `/etc/pam.d/agentd`; run core as a **system** systemd unit; add its + account to the `shadow` group (option 1) or enroll the host in SSSD (option 2). +2. Set `AGENTD_PAM_ENABLED=true`, restart core. +3. Log in as a real OS user: + ```bash + curl -sX POST localhost:7000/auth/login \ + -H 'content-type: application/json' \ + -d '{"username":"","password":""}' + ``` + Expect `200` + a token, and a JIT-created user (`auth_provider: "pam"`) with a + personal organization. +4. Wrong password → `401`. Expired/locked account (rejected by `acct_mgmt`) → `401`. +5. A registered `local` user still logs in by password (escape hatch intact). +6. Negative control: with core running as `systemd --user` (no shadow access), + confirm PAM logins fail — demonstrating why the system-service requirement + exists. diff --git a/tests/integration/cli_integration.sh b/tests/integration/cli_integration.sh new file mode 100755 index 00000000..fcfe4513 --- /dev/null +++ b/tests/integration/cli_integration.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +# +# agentd CLI integration test +# ============================ +# +# Exercises agentd functionality two ways: +# +# * direct — talk straight to a service on its own port (HTTP via curl). +# Validates a service in isolation; no core gateway, no auth. +# * gateway — drive the `agent` CLI, which always routes through the core +# gateway (`{core_url}/api/v1/{service}`). Validates routing, +# auth, and CLI rendering end-to-end. Mutating CLI tests need a +# session token (run `agent auth login` first, or set AGENT_TOKEN). +# +# Running both pinpoints failures: if direct passes but gateway fails, the +# fault is in the core proxy or the CLI path, not the service. +# +# SAFETY +# * Read-only health probes run unconditionally. +# * Mutating tests refuse to run against a non-localhost target unless +# --allow-remote is given. +# * Every record this script creates is tagged with a unique run marker and +# deleted on exit (disable with --no-cleanup). +# +# This script never starts services. Bring the dev stack up first, e.g.: +# overmind start # or: foreman start (uses ./Procfile) +# then run this script. Test groups whose service is down are SKIPped. +# +# Usage: +# tests/integration/cli_integration.sh [--mode direct|gateway|both] +# [--prod] [--allow-remote] +# [--no-cleanup] [-h|--help] +# +# Env overrides: +# AGENT_BIN path to the CLI binary (else auto-resolved) +# AGENTD_HOST host for direct service calls (default 127.0.0.1) +# AGENTD__PORT override a service port (e.g. AGENTD_MEMORY_PORT) +# AGENTD_CORE_SERVICE_URL core gateway URL the CLI uses (default per ports) +# AGENT_TOKEN bearer token for gateway mutation tests + +set -uo pipefail + +# ── Arguments ──────────────────────────────────────────────────────────────── +MODE="both" +USE_PROD=0 +ALLOW_REMOTE=0 +CLEANUP=1 + +usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } + +while [ $# -gt 0 ]; do + case "$1" in + --mode) MODE="${2:-}"; shift 2 ;; + --mode=*) MODE="${1#*=}"; shift ;; + --prod) USE_PROD=1; shift ;; + --allow-remote) ALLOW_REMOTE=1; shift ;; + --no-cleanup) CLEANUP=0; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown argument: $1" >&2; usage 1 ;; + esac +done + +case "$MODE" in direct|gateway|both) ;; *) echo "invalid --mode: $MODE" >&2; exit 2 ;; esac + +# ── Ports & hosts ──────────────────────────────────────────────────────────── +# Dev ports are 170xx; prod ports are 70xx (dev minus 10000). +PORT_BASE=17000 +[ "$USE_PROD" -eq 1 ] && PORT_BASE=7000 + +HOST="${AGENTD_HOST:-127.0.0.1}" + +port() { # port -> resolved port honoring env override + local name="$1" offset="$2" envvar + envvar="AGENTD_$(echo "$name" | tr '[:lower:]' '[:upper:]')_PORT" + echo "${!envvar:-$((PORT_BASE + offset))}" +} + +CORE_PORT=$(port core 0) +MEMORY_PORT=$(port memory 8) +NOTIFY_PORT=$(port notify 4) +KNOWLEDGE_PORT=$(port knowledge 11) +COMMUNICATE_PORT=$(port communicate 10) +ORCHESTRATOR_PORT=$(port orchestrator 6) + +CORE_URL="${AGENTD_CORE_SERVICE_URL:-http://${HOST}:${CORE_PORT}}" +MEMORY_DIRECT="http://${HOST}:${MEMORY_PORT}" + +# Unique marker so this run's data is identifiable and removable. +RUN_ID="itest-$$-$(date +%s)" +TEST_TAG="cli-itest" + +# ── Output & counters ──────────────────────────────────────────────────────── +if [ -t 1 ]; then C_G=$'\033[32m'; C_R=$'\033[31m'; C_Y=$'\033[33m'; C_B=$'\033[34m'; C_0=$'\033[0m' +else C_G=; C_R=; C_Y=; C_B=; C_0=; fi + +PASS=0; FAIL=0; SKIP=0 +declare -a FAILURES=() + +pass() { PASS=$((PASS+1)); printf ' %s✓%s %s\n' "$C_G" "$C_0" "$1"; } +fail() { FAIL=$((FAIL+1)); FAILURES+=("$1"); printf ' %s✗%s %s\n' "$C_R" "$C_0" "$1"; } +skip() { SKIP=$((SKIP+1)); printf ' %s○%s %s%s\n' "$C_Y" "$C_0" "$1" "${2:+ — $2}"; } +info() { printf ' %s·%s %s\n' "$C_B" "$C_0" "$1"; } +group() { printf '\n%s== %s ==%s\n' "$C_B" "$1" "$C_0"; } + +# ── Dependencies ───────────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { echo "curl is required" >&2; exit 3; } +HAVE_JQ=0; command -v jq >/dev/null 2>&1 && HAVE_JQ=1 + +# Extract a top-level string field from a JSON object. Prefers jq; falls back to +# a (best-effort) grep for environments without jq. +json_field() { # json_field + if [ "$HAVE_JQ" -eq 1 ]; then + printf '%s' "$2" | jq -r --arg f "$1" '.[$f] // empty' 2>/dev/null + else + printf '%s' "$2" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\([^"]*\)"/\1/' + fi +} + +# GET/POST/DELETE returning "HTTP_STATUS\nBODY"; capture both for assertions. +http() { # http [json-body] + local method="$1" url="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -sS -m 15 -o /dev/null -w '%{http_code}' -X "$method" "$url" \ + -H 'Content-Type: application/json' -d "$body" 2>/dev/null + else + curl -sS -m 15 -o /dev/null -w '%{http_code}' -X "$method" "$url" 2>/dev/null + fi +} +http_body() { # http_body [json-body] -> response body only + local method="$1" url="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -sS -m 15 -X "$method" "$url" -H 'Content-Type: application/json' -d "$body" 2>/dev/null + else + curl -sS -m 15 -X "$method" "$url" 2>/dev/null + fi +} +# Single call returning the body with the status code appended on the last line. +http_both() { # http_both -> "\n" + curl -sS -m 15 -w '\n%{http_code}' -X "$1" "$2" 2>/dev/null +} + +is_localhost() { case "$1" in localhost|127.0.0.1|::1|"[::1]") return 0 ;; *) return 1 ;; esac; } + +# ── CLI binary resolution ──────────────────────────────────────────────────── +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLI=() +resolve_cli() { + if [ -n "${AGENT_BIN:-}" ] && [ -x "${AGENT_BIN}" ]; then CLI=("$AGENT_BIN"); return; fi + for c in "$REPO_ROOT/target/debug/cli" "$REPO_ROOT/target/release/cli"; do + [ -x "$c" ] && { CLI=("$c"); return; } + done + for c in agent cli; do + command -v "$c" >/dev/null 2>&1 && { CLI=("$c"); return; } + done + # Last resort: build-and-run via cargo (slow). Warn the caller. + info "no prebuilt CLI found; falling back to 'cargo run -p cli' (slow)" + CLI=(cargo run -q -p cli --) +} + +# ── Cleanup ────────────────────────────────────────────────────────────────── +declare -a CREATED_IDS=() +drop_id() { # drop_id — remove an id from the cleanup list (already deleted) + local keep=() x + for x in "${CREATED_IDS[@]}"; do [ "$x" = "$1" ] || keep+=("$x"); done + CREATED_IDS=(${keep[@]+"${keep[@]}"}) # +-guard: empty array is OK under set -u +} +cleanup() { + [ "$CLEANUP" -eq 1 ] || { info "skipping cleanup (--no-cleanup); marker=$RUN_ID"; return; } + [ "${#CREATED_IDS[@]}" -eq 0 ] && return + printf '\n%s== Cleanup ==%s\n' "$C_B" "$C_0" + for id in "${CREATED_IDS[@]}"; do + local code; code=$(http DELETE "${MEMORY_DIRECT}/memories/${id}") + if [ "$code" = "200" ] || [ "$code" = "204" ]; then info "deleted $id" + else info "could not delete $id (HTTP $code) — remove manually"; fi + done +} +trap cleanup EXIT + +# ── Health probe ───────────────────────────────────────────────────────────── +service_up() { # service_up -> 0 if /health returns 200 + [ "$(http GET "$1/health")" = "200" ] +} + +# ── DIRECT MODE ────────────────────────────────────────────────────────────── +run_direct() { + group "Direct: service health (port $MEMORY_PORT etc.)" + local svc + for svc in "memory:$MEMORY_PORT" "notify:$NOTIFY_PORT" "knowledge:$KNOWLEDGE_PORT" \ + "communicate:$COMMUNICATE_PORT" "orchestrator:$ORCHESTRATOR_PORT"; do + local name="${svc%%:*}" p="${svc##*:}" code + code=$(http GET "http://${HOST}:${p}/health") + if [ "$code" = "200" ]; then pass "$name /health (200)" + else fail "$name /health on :$p returned ${code:-no-response}"; fi + done + + group "Direct: memory CRUD round-trip (no gateway, no auth)" + if ! service_up "$MEMORY_DIRECT"; then + skip "memory CRUD" "memory service not reachable at $MEMORY_DIRECT" + return + fi + if ! is_localhost "$HOST" && [ "$ALLOW_REMOTE" -eq 0 ]; then + skip "memory CRUD" "target $HOST is not localhost (use --allow-remote)" + return + fi + if [ "$HAVE_JQ" -eq 0 ]; then + info "jq not found — using best-effort JSON parsing" + fi + + # CREATE — content intentionally crosses a multibyte boundary near byte 49 + # so it would have tripped the old `&content[..49]` slice-panic in the CLI + # list renderer (now char-safe). + local content="${RUN_ID}: padding padding padding padding 日本語テストデータ end" + local payload + payload=$(cat </dev/null \ + | paste -sd, - 2>/dev/null) + fi + if printf '%s' "$agg_body" | grep -q '"services"'; then + skip "aggregate health (gateway OK, downstream degraded)" "unhealthy: ${down:-see body}" + else + fail "core /api/v1/health 503 with no health body: $(printf '%.120s' "$agg_body")" + fi + ;; + 401|403) skip "aggregate health" "auth required (HTTP $code)" ;; + *) fail "core /api/v1/health returned ${code:-no-response}" ;; + esac + + group "Gateway: 'agent status' (probes per-service ports)" + if "${CLI[@]}" status >/tmp/itest-status.$$ 2>&1; then + pass "agent status exited 0" + else + fail "agent status failed (exit $?) — see /tmp/itest-status.$$" + fi + + # Determine whether we have a usable token for authenticated calls. + local have_auth=0 + if [ -n "${AGENT_TOKEN:-}" ]; then have_auth=1; fi + # `agent auth login` writes a session file; a successful unauthenticated + # call below would still 401, so gate CRUD on token presence. + + group "Gateway: memory through the CLI (regression guard)" + if ! is_localhost "$HOST" && [ "$ALLOW_REMOTE" -eq 0 ]; then + skip "CLI memory CRUD" "target not localhost (use --allow-remote)" + return + fi + if [ "$have_auth" -eq 0 ]; then + skip "CLI memory CRUD" "no AGENT_TOKEN and gateway requires auth — run 'agent auth login'" + info "tip: read-only 'agent memory list' is still attempted below" + fi + + # The list renderer is the path that previously panicked on multibyte + # content. Even with no rows it must exit cleanly; with rows it must not + # panic. This is the core regression guard. + if "${CLI[@]}" memory list --tag "$TEST_TAG" --limit 50 >/tmp/itest-list.$$ 2>&1; then + pass "agent memory list rendered without panic" + else + # A 401 (auth) is an expected non-panic failure; a panic is not. + if grep -qi 'panic\|char boundary\|byte index' /tmp/itest-list.$$; then + fail "agent memory list PANICKED (regression!) — see /tmp/itest-list.$$" + else + skip "agent memory list" "non-panic error (likely auth); see /tmp/itest-list.$$" + fi + fi + + [ "$have_auth" -eq 1 ] || return + + # Full CLI round-trip through the gateway with auth. + local content="${RUN_ID} gateway 日本語 round-trip padding padding padding end" + local out id + out=$("${CLI[@]}" --json memory remember "$content" \ + --created-by "$RUN_ID" --type information --tags "$TEST_TAG" 2>/tmp/itest-remember.$$) + id=$(json_field id "$out") + if [ -n "$id" ]; then pass "agent memory remember ($id)"; CREATED_IDS+=("$id") + else fail "agent memory remember — no id; see /tmp/itest-remember.$$"; return; fi + + if "${CLI[@]}" memory list --created-by "$RUN_ID" >/tmp/itest-list2.$$ 2>&1 \ + && grep -q "$id" /tmp/itest-list2.$$; then + pass "agent memory list shows the new record (human render)" + else + fail "agent memory list missing $id (or render error); see /tmp/itest-list2.$$" + fi + + if "${CLI[@]}" memory recall "$id" >/dev/null 2>&1; then pass "agent memory recall" + else fail "agent memory recall failed"; fi + + if "${CLI[@]}" memory forget "$id" >/dev/null 2>&1; then + pass "agent memory forget" + drop_id "$id" + else + fail "agent memory forget failed" + fi +} + +# ── Run ────────────────────────────────────────────────────────────────────── +printf '%sagentd CLI integration test%s (mode=%s, %s ports, marker=%s)\n' \ + "$C_B" "$C_0" "$MODE" "$([ "$USE_PROD" -eq 1 ] && echo prod || echo dev)" "$RUN_ID" + +case "$MODE" in + direct) run_direct ;; + gateway) run_gateway ;; + both) run_direct; run_gateway ;; +esac + +# ── Summary ────────────────────────────────────────────────────────────────── +printf '\n%s== Summary ==%s\n' "$C_B" "$C_0" +printf ' passed: %s%d%s failed: %s%d%s skipped: %s%d%s\n' \ + "$C_G" "$PASS" "$C_0" "$C_R" "$FAIL" "$C_0" "$C_Y" "$SKIP" "$C_0" +if [ "$FAIL" -gt 0 ]; then + printf '\n%sFailures:%s\n' "$C_R" "$C_0" + for f in "${FAILURES[@]}"; do printf ' - %s\n' "$f"; done + exit 1 +fi +exit 0 diff --git a/ui/bun.lock b/ui/bun.lock index 84dea622..431bafd1 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -23,10 +23,12 @@ "@xterm/xterm": "^6.0.0", "esbuild": "0.27.3", "lucide-react": "^0.577.0", + "p5": "^1.11.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.13.1", "react-shiki": "^0.9.2", + "vanta": "^0.5.24", "yaml": "^2.9.0", "zustand": "4.5.7", }, @@ -40,6 +42,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest-axe": "^3.5.9", "@types/node": "^24.6.0", + "@types/p5": "^1.7.7", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", @@ -554,6 +557,8 @@ "@types/node": ["@types/node@24.11.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw=="], + "@types/p5": ["@types/p5@1.7.7", "", {}, "sha512-WFuP7jqc5CkkMtCK/NphgvMnJz1Qi9CMuK7t6xLu/tuXkRdGQA4q4AD0dUYcChC0Oibe8PE8gbKSFPNF0BqVNw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1062,6 +1067,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p5": ["p5@1.11.13", "", {}, "sha512-gfGo4AkyuNMs6Ko7UNFM9K2edqFRGyLrFaYUB+XXF127JVdEPu0BIaC5uDDNJpsRMOD9hJMUpsOH4HkfuNhvhA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1248,6 +1255,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vanta": ["vanta@0.5.24", "", {}, "sha512-fvieEbHy1ZS23zrcX+topzqAgA4Uct1enngOEWLFBgs9TtOf6RDFOYatH7KSVdrABzQDMCQ5myQy+nTSZZwLzg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], diff --git a/ui/package.json b/ui/package.json index e3d9402b..db99805b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,10 +34,12 @@ "@xterm/xterm": "^6.0.0", "esbuild": "0.27.3", "lucide-react": "^0.577.0", + "p5": "^1.11.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.13.1", "react-shiki": "^0.9.2", + "vanta": "^0.5.24", "yaml": "^2.9.0", "zustand": "4.5.7" }, @@ -51,6 +53,7 @@ "@testing-library/user-event": "^14.6.1", "@types/jest-axe": "^3.5.9", "@types/node": "^24.6.0", + "@types/p5": "^1.7.7", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.4", diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index 24384117..4c311ae9 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -1,8 +1,14 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { authApi } from "@/services/auth"; import { useAuthStore } from "@/stores/authStore"; +// The solid backdrop Vanta's topology effect renders onto (its `backgroundColor` +// below, as a CSS hex). We paint it on the container synchronously so the login +// screen shows the final backdrop on first paint instead of flashing the gray +// theme background while p5/Vanta load and initialize. +const VANTA_BACKGROUND = "#220000"; + export function LoginPage() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -11,6 +17,47 @@ export function LoginPage() { const { login } = useAuthStore(); const navigate = useNavigate(); const location = useLocation(); + + // Animated Vanta "topology" backdrop. Scoped to this component: it is created + // when the login route mounts and destroyed on unmount, so it never runs on + // any other page. p5 (~1MB) and Vanta are heavy and only used here, so we + // load them lazily off the main bundle — the container already shows the + // final backdrop color, so the animated canvas simply fades in once these + // chunks resolve and the effect initializes. Vanta TOPOLOGY is a p5-based + // effect, so we hand it the p5 constructor explicitly rather than a global. + const vantaRef = useRef(null); + const vantaEffect = useRef<{ destroy: () => void } | null>(null); + const [vantaReady, setVantaReady] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + const [{ default: p5 }, { default: TOPOLOGY }] = await Promise.all([ + import("p5"), + import("vanta/dist/vanta.topology.min"), + ]); + if (cancelled || !vantaRef.current || vantaEffect.current) return; + vantaEffect.current = TOPOLOGY({ + el: vantaRef.current, + p5, + mouseControls: true, + touchControls: true, + gyroControls: false, + minHeight: 200.0, + minWidth: 200.0, + scale: 1.0, + scaleMobile: 1.0, + color: 0x7f5757, + backgroundColor: 0x220000, + }); + setVantaReady(true); + })(); + return () => { + cancelled = true; + vantaEffect.current?.destroy(); + vantaEffect.current = null; + }; + }, []); const from = (location.state as { from?: { pathname?: string } } | null)?.from ?.pathname ?? "/"; @@ -31,8 +78,16 @@ export function LoginPage() { }; return ( -
-
+
+