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:
parent
35f7597c3c
commit
30b9df761a
6 changed files with 103 additions and 36 deletions
|
|
@ -2003,8 +2003,8 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
// Model: ZEROCLAW_MODEL
|
||||
if let Ok(model) = std::env::var("ZEROCLAW_MODEL") {
|
||||
// Model: ZEROCLAW_MODEL or MODEL
|
||||
if let Ok(model) = std::env::var("ZEROCLAW_MODEL").or_else(|_| std::env::var("MODEL")) {
|
||||
if !model.is_empty() {
|
||||
self.default_model = Some(model);
|
||||
}
|
||||
|
|
@ -3292,6 +3292,22 @@ default_temperature = 0.7
|
|||
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]
|
||||
fn env_override_workspace() {
|
||||
let _env_guard = env_override_test_guard();
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
|||
use crate::security::SecurityPolicy;
|
||||
use crate::tools;
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Query, State},
|
||||
|
|
@ -176,6 +176,7 @@ fn client_key_from_headers(headers: &HeaderMap) -> String {
|
|||
/// Shared state for all axum handlers
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Mutex<Config>>,
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub model: String,
|
||||
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)."
|
||||
);
|
||||
}
|
||||
let config_state = Arc::new(Mutex::new(config.clone()));
|
||||
|
||||
let addr: SocketAddr = format!("{host}:{port}").parse()?;
|
||||
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
|
||||
let state = AppState {
|
||||
config: config_state,
|
||||
provider,
|
||||
model,
|
||||
temperature,
|
||||
|
|
@ -422,8 +425,20 @@ async fn handle_pair(State(state): State<AppState>, headers: HeaderMap) -> impl
|
|||
match state.pairing.try_pair(code) {
|
||||
Ok(Some(token)) => {
|
||||
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!({
|
||||
"paired": true,
|
||||
"persisted": true,
|
||||
"token": 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
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WebhookBody {
|
||||
|
|
@ -836,6 +859,33 @@ mod tests {
|
|||
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]
|
||||
fn webhook_memory_key_is_unique() {
|
||||
let key1 = webhook_memory_key();
|
||||
|
|
@ -997,6 +1047,7 @@ mod tests {
|
|||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(Config::default())),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
|
|
@ -1045,6 +1096,7 @@ mod tests {
|
|||
let memory: Arc<dyn Memory> = tracking_impl.clone();
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(Config::default())),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
|
|
@ -1102,6 +1154,7 @@ mod tests {
|
|||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(Config::default())),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
|
|
@ -1136,6 +1189,7 @@ mod tests {
|
|||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(Config::default())),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
|
|
@ -1173,6 +1227,7 @@ mod tests {
|
|||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(Config::default())),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
|
|
|
|||
|
|
@ -115,9 +115,8 @@ fn status(config: &Config) -> Result<()> {
|
|||
if cfg!(target_os = "windows") {
|
||||
let _ = config;
|
||||
let task_name = windows_task_name();
|
||||
let out = run_capture(
|
||||
Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]),
|
||||
);
|
||||
let out =
|
||||
run_capture(Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]));
|
||||
match out {
|
||||
Ok(text) => {
|
||||
let running = text.contains("Running");
|
||||
|
|
@ -167,9 +166,7 @@ fn uninstall(config: &Config) -> Result<()> {
|
|||
|
||||
if cfg!(target_os = "windows") {
|
||||
let task_name = windows_task_name();
|
||||
let _ = run_checked(
|
||||
Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]),
|
||||
);
|
||||
let _ = run_checked(Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]));
|
||||
// Remove the wrapper script
|
||||
let wrapper = config
|
||||
.config_path
|
||||
|
|
@ -288,20 +285,18 @@ fn install_windows(config: &Config) -> Result<()> {
|
|||
.args(["/Delete", "/TN", task_name, "/F"])
|
||||
.output();
|
||||
|
||||
run_checked(
|
||||
Command::new("schtasks").args([
|
||||
"/Create",
|
||||
"/TN",
|
||||
task_name,
|
||||
"/SC",
|
||||
"ONLOGON",
|
||||
"/TR",
|
||||
&format!("\"{}\"", wrapper.display()),
|
||||
"/RL",
|
||||
"HIGHEST",
|
||||
"/F",
|
||||
]),
|
||||
)?;
|
||||
run_checked(Command::new("schtasks").args([
|
||||
"/Create",
|
||||
"/TN",
|
||||
task_name,
|
||||
"/SC",
|
||||
"ONLOGON",
|
||||
"/TR",
|
||||
&format!("\"{}\"", wrapper.display()),
|
||||
"/RL",
|
||||
"HIGHEST",
|
||||
"/F",
|
||||
]))?;
|
||||
|
||||
println!("✅ Installed Windows scheduled task: {}", task_name);
|
||||
println!(" Wrapper: {}", wrapper.display());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue