diff --git a/.github/workflows/engine-client.yml b/.github/workflows/engine-client.yml new file mode 100644 index 0000000000..7613fd1c99 --- /dev/null +++ b/.github/workflows/engine-client.yml @@ -0,0 +1,40 @@ +# The Rust client for the shared daemon (crates/engine-client). Runs only when the crate or +# this file changes: the rest of the repository is TypeScript and does not need a Rust toolchain. +name: engine-client + +on: + pull_request: + paths: + - "crates/engine-client/**" + - ".github/workflows/engine-client.yml" + push: + branches: [develop, main] + paths: + - "crates/engine-client/**" + - ".github/workflows/engine-client.yml" + +permissions: + contents: read + +jobs: + check: + name: engine-client (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + working-directory: crates/engine-client + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/engine-client + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --locked diff --git a/crates/engine-client/Cargo.lock b/crates/engine-client/Cargo.lock new file mode 100644 index 0000000000..85de0b8881 --- /dev/null +++ b/crates/engine-client/Cargo.lock @@ -0,0 +1,359 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "redrob-engine-client" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "dirs", + "log", + "serde", + "serde_json", + "tempfile", + "ureq", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/engine-client/Cargo.toml b/crates/engine-client/Cargo.toml new file mode 100644 index 0000000000..1fd284ad30 --- /dev/null +++ b/crates/engine-client/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "redrob-engine-client" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +repository = "https://github.com/redrob-labs/redrob-code" +description = "Find, start, watch and stop the shared Redrob Code daemon from a Rust app: the record in ~/.redrob/run/server.json, its health, who is attached, and the engine's OpenAI-compatible base" +# Lives beside the engine contract it implements (docs/LOCAL-ENGINE-API.md, section 3) so that +# every Rust Redrob app (Shape, Canvas, Query, Recall, the launcher) reaches the one daemon the +# same way. Apps depend on it as a git dependency pinned to the engine tag they target: +# redrob-engine-client = { git = "https://github.com/redrob-labs/redrob-code", tag = "v0.6.3" } +# No crates.io publish yet; flip `publish` when a registry token exists. +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" +dirs = "6" +base64 = "0.22" +# Plain HTTP to 127.0.0.1 only: the daemon never listens elsewhere, so no TLS is built in. +ureq = { version = "3.4", default-features = false } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/engine-client/examples/live.rs b/crates/engine-client/examples/live.rs new file mode 100644 index 0000000000..393bbf70a0 --- /dev/null +++ b/crates/engine-client/examples/live.rs @@ -0,0 +1,18 @@ +//! Live check against a real daemon: require it, print the base and the headers an app would send. +//! Run with `REDROB_CODE_BIN= REDROB_RUN_DIR= cargo run --example live`. +fn main() { + let record = match redrob_engine_client::require_as("shape", 1) { + Ok(r) => r, + Err(e) => { + eprintln!("unavailable: {e}"); + std::process::exit(2); + } + }; + println!("base={}", redrob_engine_client::openai_base(&record)); + println!("pid={} version={}", record.pid, record.version); + for (k, v) in redrob_engine_client::app_headers(&record, "shape", Some("doc-1")) { + println!("{k}: {}", if k == "Authorization" { "Basic …".to_string() } else { v }); + } + let clients = redrob_engine_client::clients(&record).expect("clients"); + println!("attached={}", clients.clients.len()); +} diff --git a/crates/engine-client/rustfmt.toml b/crates/engine-client/rustfmt.toml new file mode 100644 index 0000000000..3121924d65 --- /dev/null +++ b/crates/engine-client/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/crates/engine-client/src/lib.rs b/crates/engine-client/src/lib.rs new file mode 100644 index 0000000000..32b1f28361 --- /dev/null +++ b/crates/engine-client/src/lib.rs @@ -0,0 +1,335 @@ +//! The shared Redrob Code daemon, from a Rust app. +//! +//! One `redrob serve --register` runs per user and every Redrob app attaches to it (the engine's +//! `docs/LOCAL-ENGINE-API.md`, section 3). The daemon leaves its record at +//! `$HOME/.redrob/run/server.json` (mode 0600): url, port, pid, version and the Basic secret, drawn +//! fresh on every start. This crate reads that record, checks the daemon answers with it, lists who +//! is attached, starts one through `redrob service start --json` when none answers, and stops it. +//! +//! Nothing here reads the engine's configuration or its credentials: the daemon's record is the +//! only file touched, and the only process reached is the one it names. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +/// The record `redrob serve --register` writes, and `redrob service start --json` prints. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// Random per start; a different id means the daemon restarted. + pub id: String, + pub version: String, + pub url: String, + pub port: u16, + pub pid: u32, + /// The Basic password (username `redrob`). Never log it. + pub secret: String, +} + +impl std::fmt::Display for Record { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (pid {}, v{})", self.url, self.pid, self.version) + } +} + +/// One app attached to the daemon. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Client { + pub id: String, + pub app: String, + #[serde(default)] + pub pid: Option, + #[serde(rename = "attachedAt")] + pub attached_at: u64, + #[serde(rename = "lastSeen")] + pub last_seen: u64, +} + +/// `GET /api/clients`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Clients { + pub clients: Vec, + #[serde(rename = "ttlMs")] + pub ttl_ms: u64, + /// How long nobody has been attached, in ms; absent while someone is. + #[serde(rename = "idleForMs", default)] + pub idle_for_ms: Option, + /// The daemon exits after this long with nobody attached; absent when it stays up. + #[serde(rename = "idleExitAfterMs", default)] + pub idle_exit_after_ms: Option, +} + +/// `$HOME/.redrob/run`, or `REDROB_RUN_DIR`. +pub fn run_dir() -> Option { + if let Some(d) = std::env::var_os("REDROB_RUN_DIR").filter(|v| !v.is_empty()) { + return Some(PathBuf::from(d)); + } + dirs::home_dir().map(|h| h.join(".redrob").join("run")) +} + +pub fn record_path() -> Option { + run_dir().map(|d| d.join("server.json")) +} + +/// The record on disk, or `None` when there is none or it does not parse. +pub fn read_record() -> Option { + read_record_at(&record_path()?) +} + +pub fn read_record_at(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// The `Authorization` header value for the daemon. +pub fn authorization(secret: &str) -> String { + format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("redrob:{secret}"))) +} + +/// Where the OpenAI-compatible route lives: `/v1` (chat completions is `/v1/chat/completions`). +pub fn openai_base(record: &Record) -> String { + format!("{}/v1", record.url.trim_end_matches('/')) +} + +/// The request header that names the calling app on the shared daemon. +pub const APP_HEADER: &str = "x-redrob-app"; +/// The request header that names the caller's own session on the shared daemon. +pub const SESSION_HEADER: &str = "x-redrob-session"; + +/// Headers an app puts on every `/v1/chat/completions` call: the daemon's `Authorization`, the app +/// name, and (when the app has one) its session id. The engine forwards the labels to the provider +/// under the names its own agent runner uses, so the Console groups both paths alike. One daemon +/// serves every app, so the app name must travel on the request -- the daemon's environment names +/// nobody. +pub fn app_headers(record: &Record, app: &str, session: Option<&str>) -> Vec<(&'static str, String)> { + let mut headers = vec![("Authorization", authorization(&record.secret)), (APP_HEADER, app.to_string())]; + if let Some(session) = session.map(str::trim).filter(|s| !s.is_empty()) { + headers.push((SESSION_HEADER, session.to_string())); + } + headers +} + +/// Why an app has no engine to talk to, for the message it shows. There is deliberately no +/// "fall back to the provider directly" arm: two paths would mean the user cannot tell whether +/// masking applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unavailable { + /// No `redrob` binary: not installed. Point at the launcher or the Code install. + NotInstalled, + /// Installed, but `redrob service start` did not yield a healthy daemon. + StartFailed(String), +} + +impl std::fmt::Display for Unavailable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Unavailable::NotInstalled => write!(f, "Redrob Code is not installed; install it (the launcher does this) and try again"), + Unavailable::StartFailed(reason) => write!(f, "the Redrob Code engine could not be started: {reason}"), + } + } +} + +/// [`connect_or_start_as`] with the two failure kinds told apart, for apps that render them. +pub fn require_as(app: &str, idle_exit_minutes: u32) -> Result { + if !installed() { + return Err(Unavailable::NotInstalled); + } + connect_or_start_as(app, idle_exit_minutes).map_err(Unavailable::StartFailed) +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(3))).http_status_as_error(false).build().into() +} + +fn get_json Deserialize<'de>>(record: &Record, path: &str) -> Result { + let url = format!("{}{}", record.url.trim_end_matches('/'), path); + let mut response = agent().get(&url).header("Authorization", &authorization(&record.secret)).call().map_err(|e| e.to_string())?; + let status = response.status().as_u16(); + if status != 200 { + return Err(format!("{path} answered {status}")); + } + let text = response.body_mut().read_to_string().map_err(|e| e.to_string())?; + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// Whether the daemon the record names answers `/api/health` with the record's secret. +pub fn healthy(record: &Record) -> bool { + #[derive(Deserialize)] + struct Health { + healthy: bool, + } + matches!(get_json::(record, "/api/health"), Ok(Health { healthy: true })) +} + +/// Who is attached, and the idle policy. +pub fn clients(record: &Record) -> Result { + get_json(record, "/api/clients") +} + +/// The daemon as it stands: the record, if its daemon answers. +pub fn current() -> Option { + let record = read_record()?; + healthy(&record).then_some(record) +} + +/// Where the engine binary is, by the engine's own resolution order: `REDROB_CODE_BIN`, +/// `~/.redrob/bin/redrob`, then `redrob` on PATH. +pub fn redrob_bin() -> PathBuf { + if let Some(b) = std::env::var_os("REDROB_CODE_BIN").filter(|v| !v.is_empty()) { + return PathBuf::from(b); + } + if let Some(home) = dirs::home_dir() { + let name = if cfg!(windows) { "redrob.exe" } else { "redrob" }; + let p = home.join(".redrob").join("bin").join(name); + if p.is_file() { + return p; + } + } + PathBuf::from("redrob") +} + +/// Whether an engine binary can be found at all. +pub fn installed() -> bool { + let bin = redrob_bin(); + if bin.components().count() > 1 { + return bin.is_file(); + } + // Bare name: look along PATH. + let Some(path) = std::env::var_os("PATH") else { return false }; + std::env::split_paths(&path).any(|d| { + let p = d.join(&bin); + p.is_file() || (cfg!(windows) && d.join(format!("{}.exe", bin.display())).is_file()) + }) +} + +/// Pulls the one JSON line out of what `redrob service start --json` printed. +pub fn parse_record_output(stdout: &str) -> Result { + let line = stdout.lines().map(str::trim).rfind(|l| l.starts_with('{')).ok_or("the engine printed no daemon record")?; + serde_json::from_str(line).map_err(|e| format!("the engine printed a record that does not parse: {e}")) +} + +/// Find the daemon or start it: `redrob service start --json`, which holds the start and upgrade +/// logic in one place (the engine). `idle_exit_minutes`: how long a daemon started here stays up +/// with nobody attached; `0` keeps it up. The daemon is started under the launcher's name; an app +/// that wants its own name on the start uses [`connect_or_start_as`]. +pub fn connect_or_start(idle_exit_minutes: u32) -> Result { + connect_or_start_as("launcher", idle_exit_minutes) +} + +/// [`connect_or_start`], naming the app that asked (`REDROB_APP` on the `redrob service start` +/// call; the daemon itself serves every app, so per-request labels go on the request as +/// `x-redrob-app`, see [`app_headers`]). +pub fn connect_or_start_as(app: &str, idle_exit_minutes: u32) -> Result { + if let Some(record) = current() { + return Ok(record); + } + let bin = redrob_bin(); + let output = Command::new(&bin) + .args(["service", "start", "--json", "--idle-exit", &idle_exit_minutes.to_string()]) + .env("REDROB_APP", app) + .output() + .map_err(|e| format!("could not run {}: {e}. Install Redrob Code first.", bin.display()))?; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + let out = String::from_utf8_lossy(&output.stdout); + return Err(format!("redrob service start failed ({}): {}", output.status, if err.trim().is_empty() { out.trim() } else { err.trim() })); + } + parse_record_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// Stop the daemon the record names: `redrob service stop`, which signals only a daemon that +/// answered with the record's own secret, so a stale pid another process now owns is never hit. +pub fn stop() -> Result { + if read_record().is_none() { + return Ok(false); + } + let bin = redrob_bin(); + let output = Command::new(&bin).args(["service", "stop"]).output().map_err(|e| format!("could not run {}: {e}", bin.display()))?; + if !output.status.success() { + return Err(format!("redrob service stop failed ({})", output.status)); + } + // The CLI prints its one word on stderr; what matters is that the record is gone. + Ok(read_record().is_none()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> Record { + Record { id: "id".into(), version: "0.6.0".into(), url: "http://127.0.0.1:4096".into(), port: 4096, pid: 1, secret: "s3cret".into() } + } + + #[test] + fn the_record_round_trips_and_reads_from_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.json"); + std::fs::write(&path, serde_json::to_string(&record()).unwrap()).unwrap(); + assert_eq!(read_record_at(&path), Some(record())); + std::fs::write(&path, "{not json").unwrap(); + assert_eq!(read_record_at(&path), None); + std::fs::write(&path, r#"{"url":"x","pid":1}"#).unwrap(); + assert_eq!(read_record_at(&path), None, "a record missing fields is not a record"); + } + + #[test] + fn the_basic_header_names_the_daemon_user() { + let header = authorization("abc"); + let encoded = header.strip_prefix("Basic ").unwrap(); + let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).unwrap(); + assert_eq!(decoded, b"redrob:abc"); + } + + #[test] + fn the_openai_base_is_v1_under_the_daemon_url() { + assert_eq!(openai_base(&record()), "http://127.0.0.1:4096/v1"); + assert_eq!(openai_base(&Record { url: "http://127.0.0.1:4096///".into(), ..record() }), "http://127.0.0.1:4096/v1"); + } + + #[test] + fn the_last_json_line_of_service_start_is_the_record() { + let out = format!("some warning\n{}\n", serde_json::to_string(&record()).unwrap()); + assert_eq!(parse_record_output(&out).unwrap(), record()); + assert!(parse_record_output("nothing here").is_err()); + } + + #[test] + fn clients_parse_with_the_optional_fields_absent_or_present() { + let c: Clients = serde_json::from_str(r#"{"clients":[],"ttlMs":90000,"idleForMs":12}"#).unwrap(); + assert_eq!(c.idle_for_ms, Some(12)); + assert_eq!(c.idle_exit_after_ms, None); + let c: Clients = + serde_json::from_str(r#"{"clients":[{"id":"a","app":"design","attachedAt":1,"lastSeen":2}],"ttlMs":90000,"idleExitAfterMs":600000}"#) + .unwrap(); + assert_eq!(c.clients[0].app, "design"); + assert_eq!(c.clients[0].pid, None); + assert_eq!(c.idle_exit_after_ms, Some(600_000)); + } + + #[test] + fn a_daemon_that_does_not_answer_is_not_healthy() { + assert!(!healthy(&Record { url: "http://127.0.0.1:9".into(), ..record() })); + } + + #[test] + fn app_headers_carry_auth_app_and_an_optional_session() { + let h = app_headers(&record(), "shape", None); + assert_eq!(h.len(), 2); + assert_eq!(h[0].0, "Authorization"); + assert_eq!(h[0].1, authorization("s3cret")); + assert_eq!(h[1], (APP_HEADER, "shape".to_string())); + let h = app_headers(&record(), "shape", Some(" doc-42 ")); + assert_eq!(h[2], (SESSION_HEADER, "doc-42".to_string())); + assert_eq!(app_headers(&record(), "shape", Some(" ")).len(), 2, "a blank session sends nothing"); + } + + #[test] + fn unavailable_tells_not_installed_from_a_start_failure() { + assert!(Unavailable::NotInstalled.to_string().contains("not installed")); + assert!(Unavailable::StartFailed("boom".into()).to_string().ends_with("boom")); + assert_ne!(Unavailable::NotInstalled, Unavailable::StartFailed(String::new())); + } +} diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index e685bd0104..d4908a59c9 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -194,6 +194,25 @@ rebuilds the instance on the new file. Anything a plugin used to read from the s belongs in this file too (for example under a key the plugin owns), because the daemon's env is nobody's. +The same goes for *which app is calling*. The agent runner labels its provider requests with +`x-redrob-app` from `REDROB_APP`, which only names the app that started a private engine. On the +shared daemon `POST /v1/chat/completions` reads the labels from the request instead: send +`x-redrob-app: ` and `x-redrob-session: ` and the engine forwards them to +the provider as `x-redrob-app` and `X-Session-Id` (plus `x-session-affinity`), the names the +runner uses, so the Console groups both paths alike. Absent headers forward nothing; no other +request header crosses. + +Detecting the engine from another language, without the SDK: `redrob service status --json` +prints the record as one JSON line and exits `0` when a healthy daemon is registered, prints +`null` and exits `1` when none is. `redrob service start --json` prints the record (starting the +daemon when needed) and exits `0`, or exits non-zero with the reason on stderr. A missing `redrob` +binary (not on `PATH`, no `REDROB_BIN`) is the "engine not installed" case: the app should say so +and point at the launcher or the Code install, never fall back to calling a provider directly -- +two paths would mean the user cannot tell whether masking applied. The Rust crate +`redrob-engine-client` (`crates/engine-client` in this repository, pinned by apps as a git +dependency on the engine tag they target) wraps all of this for Shape, Canvas, Query, Recall and +the launcher. + That file is the auth boundary — it carries the Basic secret. It is per-user by location and must stay `0600`; a multi-user machine gets one daemon per user, never a shared one. diff --git a/packages/redrob/test/server/chat-completion-labels.test.ts b/packages/redrob/test/server/chat-completion-labels.test.ts new file mode 100644 index 0000000000..7f5ad395ef --- /dev/null +++ b/packages/redrob/test/server/chat-completion-labels.test.ts @@ -0,0 +1,29 @@ +/** + * Caller labels on `POST /v1/chat/completions`. + * + * A shared daemon serves several apps at once, so the app and session a request belongs to must + * travel on the request, not in the engine's environment. The handler reads `x-redrob-app` and + * `x-redrob-session` and forwards them to the provider under the same names the agent runner uses, + * so the Console groups both paths the same way. Nothing else is forwarded. + */ +import { describe, expect, test } from "bun:test" +import { APP_HEADER, SESSION_HEADER, callerLabels } from "@redrob-code/server/handlers/chat-completion" + +describe("chat completions caller labels", () => { + test("app and session headers are forwarded under the runner's names", () => { + expect(callerLabels({ [APP_HEADER]: "shape", [SESSION_HEADER]: "ses_1" })).toEqual({ + "x-redrob-app": "shape", + "X-Session-Id": "ses_1", + "x-session-affinity": "ses_1", + }) + }) + + test("absent or blank headers send nothing, and other headers are not forwarded", () => { + expect(callerLabels({})).toEqual({}) + expect(callerLabels({ [APP_HEADER]: " ", authorization: "Basic x", "x-forwarded-for": "1.2.3.4" })).toEqual({}) + }) + + test("the app alone is enough", () => { + expect(callerLabels({ [APP_HEADER]: " canvas " })).toEqual({ "x-redrob-app": "canvas" }) + }) +}) diff --git a/packages/server/src/handlers/chat-completion.ts b/packages/server/src/handlers/chat-completion.ts index a7537fee08..ee9189cdf1 100644 --- a/packages/server/src/handlers/chat-completion.ts +++ b/packages/server/src/handlers/chat-completion.ts @@ -166,13 +166,35 @@ const resolveModel = Effect.fn("chat.resolveModel")(function* (model: string) { return yield* fromCatalogModel(info, credential) }) -const buildRequest = Effect.fn("chat.buildRequest")(function* (payload: ChatCompletionRequest) { +/** + * Caller labels forwarded to the provider, so the Console can group AI work by app and session + * exactly as it does for the agent runner. A shared daemon serves several apps at once, so these + * cannot come from the engine's own environment: each request names its app and session. They are + * labels and nothing else -- an absent header sends nothing. + */ +export const APP_HEADER = "x-redrob-app" +export const SESSION_HEADER = "x-redrob-session" + +export function callerLabels(headers: Record): Record { + const app = headers[APP_HEADER]?.trim() + const session = headers[SESSION_HEADER]?.trim() + return { + ...(app ? { [APP_HEADER]: app } : {}), + ...(session ? { "X-Session-Id": session, "x-session-affinity": session } : {}), + } +} + +const buildRequest = Effect.fn("chat.buildRequest")(function* ( + payload: ChatCompletionRequest, + labels: Record = {}, +) { const model = yield* resolveModel(payload.model) const messages = toLLMMessages(payload) const tools = toLLMTools(payload) const generation = toGeneration(payload) return LLM.request({ model, + ...(Object.keys(labels).length > 0 ? { http: { headers: labels } } : {}), messages, // Absent tools is what selects engine-owned behaviour; an empty array would // tell the provider "you may call nothing", which is a different thing. @@ -337,8 +359,9 @@ export const ChatCompletionHandler = HttpApiBuilder.group(Api, "server.chat", (h // to answer with either JSON or an event stream. The declared schema is // still the contract, so it is applied here rather than trusting the body. const payload = yield* HttpServerRequest.schemaBodyJson(ChatCompletionRequest) + const incoming = yield* HttpServerRequest.HttpServerRequest const id = completionID() - const { request, unmask } = yield* maskedRequest(yield* buildRequest(payload)) + const { request, unmask } = yield* maskedRequest(yield* buildRequest(payload, callerLabels(incoming.headers))) if (payload.stream !== true) { const response = yield* maskedGenerate(request, unmask)