fix(gateway): persist pairing tokens and honor docker config (#630)

* fix(gateway): honor config bind settings and persist pairing

Resolve docker-compose startup and restart friction by:
- using config host/port defaults for gateway/daemon unless CLI flags are passed
- persisting paired token hashes to config.toml on successful /pair
- running container default command as 'zeroclaw gateway' (no hardcoded --host/--port overrides)
- updating compose image/docs to zeroclaw-labs namespace
- adding MODEL env fallback for default_model override and targeted regression tests

* chore(ci): sync lockfile and restore rustfmt parity

Update Cargo.lock to match Cargo.toml and format src/service/mod.rs so rust quality gates stop failing with unrelated baseline drift.
This commit is contained in:
Will Sarg 2026-02-17 15:05:56 -05:00 committed by GitHub
parent 35f7597c3c
commit 30b9df761a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 103 additions and 36 deletions

17
Cargo.lock generated
View file

@ -498,9 +498,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.58" version = "4.5.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -508,9 +508,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.58" version = "4.5.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -3790,9 +3790,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.0.1+spec-1.1.0" version = "1.0.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220" checksum = "d1dfefef6a142e93f346b64c160934eb13b5594b84ab378133ac6815cb2bd57f"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
@ -3835,9 +3835,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.0.8+spec-1.1.0" version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [ dependencies = [
"winnow", "winnow",
] ]
@ -4929,6 +4929,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"regex", "regex",
"reqwest", "reqwest",
"ring",
"rppal", "rppal",
"rusqlite", "rusqlite",
"rustls", "rustls",

View file

@ -90,7 +90,7 @@ WORKDIR /zeroclaw-data
USER 65534:65534 USER 65534:65534
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["zeroclaw"] ENTRYPOINT ["zeroclaw"]
CMD ["gateway", "--port", "3000", "--host", "[::]"] CMD ["gateway"]
# ── Stage 4: Production Runtime (Distroless) ───────────────── # ── Stage 4: Production Runtime (Distroless) ─────────────────
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7 AS release FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7 AS release
@ -112,4 +112,4 @@ WORKDIR /zeroclaw-data
USER 65534:65534 USER 65534:65534
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT ["zeroclaw"] ENTRYPOINT ["zeroclaw"]
CMD ["gateway", "--port", "3000", "--host", "[::]"] CMD ["gateway"]

View file

@ -2,14 +2,14 @@
# #
# Quick start: # Quick start:
# 1. Copy this file and set your API key # 1. Copy this file and set your API key
# 2. Run: docker-compose up -d # 2. Run: docker compose up -d
# 3. Access gateway at http://localhost:3000 # 3. Access gateway at http://localhost:3000
# #
# For more info: https://github.com/theonlyhennygod/zeroclaw # For more info: https://github.com/zeroclaw-labs/zeroclaw
services: services:
zeroclaw: zeroclaw:
image: ghcr.io/theonlyhennygod/zeroclaw:latest image: ghcr.io/zeroclaw-labs/zeroclaw:latest
# Or build locally: # Or build locally:
# build: . # build: .
container_name: zeroclaw container_name: zeroclaw

View file

@ -2003,8 +2003,8 @@ impl Config {
} }
} }
// Model: ZEROCLAW_MODEL // Model: ZEROCLAW_MODEL or MODEL
if let Ok(model) = std::env::var("ZEROCLAW_MODEL") { if let Ok(model) = std::env::var("ZEROCLAW_MODEL").or_else(|_| std::env::var("MODEL")) {
if !model.is_empty() { if !model.is_empty() {
self.default_model = Some(model); self.default_model = Some(model);
} }
@ -3292,6 +3292,22 @@ default_temperature = 0.7
std::env::remove_var("ZEROCLAW_MODEL"); std::env::remove_var("ZEROCLAW_MODEL");
} }
#[test]
fn env_override_model_fallback() {
let _env_guard = env_override_test_guard();
let mut config = Config::default();
std::env::remove_var("ZEROCLAW_MODEL");
std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet");
config.apply_env_overrides();
assert_eq!(
config.default_model.as_deref(),
Some("anthropic/claude-3.5-sonnet")
);
std::env::remove_var("MODEL");
}
#[test] #[test]
fn env_override_workspace() { fn env_override_workspace() {
let _env_guard = env_override_test_guard(); let _env_guard = env_override_test_guard();

View file

@ -16,7 +16,7 @@ use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
use crate::security::SecurityPolicy; use crate::security::SecurityPolicy;
use crate::tools; use crate::tools;
use crate::util::truncate_with_ellipsis; use crate::util::truncate_with_ellipsis;
use anyhow::Result; use anyhow::{Context, Result};
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{Query, State}, extract::{Query, State},
@ -176,6 +176,7 @@ fn client_key_from_headers(headers: &HeaderMap) -> String {
/// Shared state for all axum handlers /// Shared state for all axum handlers
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub config: Arc<Mutex<Config>>,
pub provider: Arc<dyn Provider>, pub provider: Arc<dyn Provider>,
pub model: String, pub model: String,
pub temperature: f64, pub temperature: f64,
@ -203,6 +204,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
[gateway] allow_public_bind = true in config.toml (NOT recommended)." [gateway] allow_public_bind = true in config.toml (NOT recommended)."
); );
} }
let config_state = Arc::new(Mutex::new(config.clone()));
let addr: SocketAddr = format!("{host}:{port}").parse()?; let addr: SocketAddr = format!("{host}:{port}").parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
@ -355,6 +357,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
// Build shared state // Build shared state
let state = AppState { let state = AppState {
config: config_state,
provider, provider,
model, model,
temperature, temperature,
@ -422,8 +425,20 @@ async fn handle_pair(State(state): State<AppState>, headers: HeaderMap) -> impl
match state.pairing.try_pair(code) { match state.pairing.try_pair(code) {
Ok(Some(token)) => { Ok(Some(token)) => {
tracing::info!("🔐 New client paired successfully"); tracing::info!("🔐 New client paired successfully");
if let Err(err) = persist_pairing_tokens(&state.config, &state.pairing) {
tracing::error!("🔐 Pairing succeeded but token persistence failed: {err:#}");
let body = serde_json::json!({
"paired": true,
"persisted": false,
"token": token,
"message": "Paired for this process, but failed to persist token to config.toml. Check config path and write permissions.",
});
return (StatusCode::OK, Json(body));
}
let body = serde_json::json!({ let body = serde_json::json!({
"paired": true, "paired": true,
"persisted": true,
"token": token, "token": token,
"message": "Save this token — use it as Authorization: Bearer <token>" "message": "Save this token — use it as Authorization: Bearer <token>"
}); });
@ -447,6 +462,14 @@ async fn handle_pair(State(state): State<AppState>, headers: HeaderMap) -> impl
} }
} }
fn persist_pairing_tokens(config: &Arc<Mutex<Config>>, pairing: &PairingGuard) -> Result<()> {
let paired_tokens = pairing.tokens();
let mut cfg = config.lock();
cfg.gateway.paired_tokens = paired_tokens;
cfg.save()
.context("Failed to persist paired tokens to config.toml")
}
/// Webhook request body /// Webhook request body
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct WebhookBody { pub struct WebhookBody {
@ -836,6 +859,33 @@ mod tests {
assert!(store.record_if_new("req-2")); assert!(store.record_if_new("req-2"));
} }
#[test]
fn persist_pairing_tokens_writes_config_tokens() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("config.toml");
let workspace_path = temp.path().join("workspace");
let mut config = Config::default();
config.config_path = config_path.clone();
config.workspace_dir = workspace_path;
config.save().unwrap();
let guard = PairingGuard::new(true, &[]);
let code = guard.pairing_code().unwrap();
let token = guard.try_pair(&code).unwrap().unwrap();
assert!(guard.is_authenticated(&token));
let shared_config = Arc::new(Mutex::new(config));
persist_pairing_tokens(&shared_config, &guard).unwrap();
let saved = std::fs::read_to_string(config_path).unwrap();
let parsed: Config = toml::from_str(&saved).unwrap();
assert_eq!(parsed.gateway.paired_tokens.len(), 1);
let persisted = &parsed.gateway.paired_tokens[0];
assert_eq!(persisted.len(), 64);
assert!(persisted.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test] #[test]
fn webhook_memory_key_is_unique() { fn webhook_memory_key_is_unique() {
let key1 = webhook_memory_key(); let key1 = webhook_memory_key();
@ -997,6 +1047,7 @@ mod tests {
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider, provider,
model: "test-model".into(), model: "test-model".into(),
temperature: 0.0, temperature: 0.0,
@ -1045,6 +1096,7 @@ mod tests {
let memory: Arc<dyn Memory> = tracking_impl.clone(); let memory: Arc<dyn Memory> = tracking_impl.clone();
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider, provider,
model: "test-model".into(), model: "test-model".into(),
temperature: 0.0, temperature: 0.0,
@ -1102,6 +1154,7 @@ mod tests {
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider, provider,
model: "test-model".into(), model: "test-model".into(),
temperature: 0.0, temperature: 0.0,
@ -1136,6 +1189,7 @@ mod tests {
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider, provider,
model: "test-model".into(), model: "test-model".into(),
temperature: 0.0, temperature: 0.0,
@ -1173,6 +1227,7 @@ mod tests {
let memory: Arc<dyn Memory> = Arc::new(MockMemory); let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState { let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider, provider,
model: "test-model".into(), model: "test-model".into(),
temperature: 0.0, temperature: 0.0,

View file

@ -115,9 +115,8 @@ fn status(config: &Config) -> Result<()> {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
let _ = config; let _ = config;
let task_name = windows_task_name(); let task_name = windows_task_name();
let out = run_capture( let out =
Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]), run_capture(Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]));
);
match out { match out {
Ok(text) => { Ok(text) => {
let running = text.contains("Running"); let running = text.contains("Running");
@ -167,9 +166,7 @@ fn uninstall(config: &Config) -> Result<()> {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
let task_name = windows_task_name(); let task_name = windows_task_name();
let _ = run_checked( let _ = run_checked(Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]));
Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]),
);
// Remove the wrapper script // Remove the wrapper script
let wrapper = config let wrapper = config
.config_path .config_path
@ -288,20 +285,18 @@ fn install_windows(config: &Config) -> Result<()> {
.args(["/Delete", "/TN", task_name, "/F"]) .args(["/Delete", "/TN", task_name, "/F"])
.output(); .output();
run_checked( run_checked(Command::new("schtasks").args([
Command::new("schtasks").args([ "/Create",
"/Create", "/TN",
"/TN", task_name,
task_name, "/SC",
"/SC", "ONLOGON",
"ONLOGON", "/TR",
"/TR", &format!("\"{}\"", wrapper.display()),
&format!("\"{}\"", wrapper.display()), "/RL",
"/RL", "HIGHEST",
"HIGHEST", "/F",
"/F", ]))?;
]),
)?;
println!("✅ Installed Windows scheduled task: {}", task_name); println!("✅ Installed Windows scheduled task: {}", task_name);
println!(" Wrapper: {}", wrapper.display()); println!(" Wrapper: {}", wrapper.display());