Files
optimclaw/src/worker/api.rs
T
76375f2eaa refactor: centralize test credential constants into testing::credentials (#829)
* refactor: centralize test credential constants into testing::credentials

Scattered test credential strings (API keys, OAuth tokens, crypto keys,
Telegram tokens, session tokens) across ~25 files made security auditing
harder and created unnecessary duplication. Centralize all test-only fake
credentials into a new `src/testing/credentials.rs` module with named
constants and a shared `test_secrets_store()` helper.

- Convert `src/testing.rs` to directory module (`src/testing/mod.rs`)
- Add `src/testing/credentials.rs` with ~30 named constants
- Replace hardcoded literals in 24 source files
- Deduplicate `test_store()` helper (was copy-pasted in 3 files)
- Leave leak_detector/shell/signature tests as-is (inline values
  aid readability for pattern detection tests)

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: replace real Telegram bot token with obviously fake test stub

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Update src/testing/credentials.rs

Co-authored-by: Copilot <[email protected]>

* Update src/testing/credentials.rs

Co-authored-by: Copilot <[email protected]>

* refactor: address PR review feedback on test credentials

- Fix TEST_CRYPTO_KEY doc comment ("32-byte hex" → "32-character key string")
- Rename confusing "real"/"fake" Anthropic constant names and values
- Change TEST_STRIPE_KEY from "sk-live" to "sk_test_fake123" to avoid scanners
- Use test_secrets_store() helper in orchestrator and http tool tests
- Clarify config_round_trip.rs doc comment about integration test visibility

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-03-10 13:25:32 -07:00

474 lines
15 KiB
Rust

//! HTTP client for worker-to-orchestrator communication.
//!
//! Every request includes a bearer token from `IRONCLAW_WORKER_TOKEN` env var.
//! The orchestrator validates this token is scoped to the correct job.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::WorkerError;
use crate::llm::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, ToolCall,
ToolCompletionRequest, ToolCompletionResponse, ToolDefinition,
};
/// HTTP client that a container worker uses to talk to the orchestrator.
pub struct WorkerHttpClient {
client: reqwest::Client,
orchestrator_url: String,
job_id: Uuid,
token: String,
}
/// Status update sent from worker to orchestrator.
#[derive(Debug, Serialize, Deserialize)]
pub struct StatusUpdate {
pub state: String,
pub message: Option<String>,
pub iteration: u32,
}
/// Job description fetched from orchestrator.
#[derive(Debug, Serialize, Deserialize)]
pub struct JobDescription {
pub title: String,
pub description: String,
pub project_dir: Option<String>,
}
/// Completion result from the orchestrator (proxied from the real LLM).
#[derive(Debug, Serialize, Deserialize)]
pub struct ProxyCompletionRequest {
pub messages: Vec<ChatMessage>,
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub stop_sequences: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProxyCompletionResponse {
pub content: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub finish_reason: String,
#[serde(default)]
pub cache_read_input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProxyToolCompletionRequest {
pub messages: Vec<ChatMessage>,
pub tools: Vec<ToolDefinition>,
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub tool_choice: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProxyToolCompletionResponse {
pub content: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub input_tokens: u32,
pub output_tokens: u32,
pub finish_reason: String,
#[serde(default)]
pub cache_read_input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
}
/// Completion result for the worker to report when done.
#[derive(Debug, Serialize, Deserialize)]
pub struct CompletionReport {
pub success: bool,
pub message: Option<String>,
pub iterations: u32,
}
/// Payload sent to the orchestrator for each job event (shared by worker and Claude Code bridge).
#[derive(Debug, Serialize, Deserialize)]
pub struct JobEventPayload {
pub event_type: String,
pub data: serde_json::Value,
}
/// Response from the prompt polling endpoint.
#[derive(Debug, Deserialize)]
pub struct PromptResponse {
pub content: String,
#[serde(default)]
pub done: bool,
}
/// A single credential delivered from the orchestrator to a container worker.
///
/// Shared between the orchestrator endpoint and the worker client.
#[derive(Debug, Serialize, Deserialize)]
pub struct CredentialResponse {
pub env_var: String,
pub value: String,
}
impl WorkerHttpClient {
/// Create a new client from environment.
///
/// Reads `IRONCLAW_WORKER_TOKEN` from the environment.
pub fn from_env(orchestrator_url: String, job_id: Uuid) -> Result<Self, WorkerError> {
let token =
std::env::var("IRONCLAW_WORKER_TOKEN").map_err(|_| WorkerError::MissingToken)?;
Ok(Self {
client: reqwest::Client::new(),
orchestrator_url: orchestrator_url.trim_end_matches('/').to_string(),
job_id,
token,
})
}
/// Create with an explicit token (for testing).
pub fn new(orchestrator_url: String, job_id: Uuid, token: String) -> Self {
Self {
client: reqwest::Client::new(),
orchestrator_url: orchestrator_url.trim_end_matches('/').to_string(),
job_id,
token,
}
}
/// Get the base orchestrator URL.
pub fn orchestrator_url(&self) -> &str {
&self.orchestrator_url
}
fn url(&self, path: &str) -> String {
format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path)
}
/// Send a GET request, check the status, and deserialize the JSON body.
async fn get_json<T: serde::de::DeserializeOwned>(
&self,
path: &str,
context: &str,
) -> Result<T, WorkerError> {
let resp = self
.client
.get(self.url(path))
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| WorkerError::ConnectionFailed {
url: self.orchestrator_url.clone(),
reason: e.to_string(),
})?;
if !resp.status().is_success() {
return Err(WorkerError::OrchestratorRejected {
job_id: self.job_id,
reason: format!("{} returned {}", context, resp.status()),
});
}
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: failed to parse response: {}", context, e),
})
}
/// Send a POST request with a JSON body, check the status, and deserialize the response.
async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: &B,
context: &str,
) -> Result<T, WorkerError> {
let resp = self
.client
.post(self.url(path))
.bearer_auth(&self.token)
.json(body)
.send()
.await
.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: {}", context, e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(WorkerError::LlmProxyFailed {
reason: format!("{}: orchestrator returned {}: {}", context, status, body),
});
}
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: failed to parse response: {}", context, e),
})
}
/// Fetch the job description from the orchestrator.
pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
self.get_json("job", "GET /job").await
}
/// Proxy an LLM completion request through the orchestrator.
pub async fn llm_complete(
&self,
request: &CompletionRequest,
) -> Result<CompletionResponse, WorkerError> {
let proxy_req = ProxyCompletionRequest {
messages: request.messages.clone(),
model: request.model.clone(),
max_tokens: request.max_tokens,
temperature: request.temperature,
stop_sequences: request.stop_sequences.clone(),
};
let proxy_resp: ProxyCompletionResponse = self
.post_json("llm/complete", &proxy_req, "LLM complete")
.await?;
Ok(CompletionResponse {
content: proxy_resp.content,
input_tokens: proxy_resp.input_tokens,
output_tokens: proxy_resp.output_tokens,
finish_reason: parse_finish_reason(&proxy_resp.finish_reason),
cache_read_input_tokens: proxy_resp.cache_read_input_tokens,
cache_creation_input_tokens: proxy_resp.cache_creation_input_tokens,
})
}
/// Proxy an LLM tool completion request through the orchestrator.
pub async fn llm_complete_with_tools(
&self,
request: &ToolCompletionRequest,
) -> Result<ToolCompletionResponse, WorkerError> {
let proxy_req = ProxyToolCompletionRequest {
messages: request.messages.clone(),
tools: request.tools.clone(),
model: request.model.clone(),
max_tokens: request.max_tokens,
temperature: request.temperature,
tool_choice: request.tool_choice.clone(),
};
let proxy_resp: ProxyToolCompletionResponse = self
.post_json("llm/complete_with_tools", &proxy_req, "LLM tool complete")
.await?;
Ok(ToolCompletionResponse {
content: proxy_resp.content,
tool_calls: proxy_resp.tool_calls,
input_tokens: proxy_resp.input_tokens,
output_tokens: proxy_resp.output_tokens,
finish_reason: parse_finish_reason(&proxy_resp.finish_reason),
cache_read_input_tokens: proxy_resp.cache_read_input_tokens,
cache_creation_input_tokens: proxy_resp.cache_creation_input_tokens,
})
}
/// Report status to the orchestrator.
pub async fn report_status(&self, update: &StatusUpdate) -> Result<(), WorkerError> {
let resp = self
.client
.post(self.url("status"))
.bearer_auth(&self.token)
.json(update)
.send()
.await
.map_err(|e| WorkerError::ConnectionFailed {
url: self.orchestrator_url.clone(),
reason: e.to_string(),
})?;
if !resp.status().is_success() {
tracing::warn!(
"Status report failed with {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Ok(())
}
/// Post a job event to the orchestrator (fire-and-forget style, logs on failure).
pub async fn post_event(&self, payload: &JobEventPayload) {
let resp = self
.client
.post(self.url("event"))
.bearer_auth(&self.token)
.json(payload)
.send()
.await;
match resp {
Ok(r) if !r.status().is_success() => {
tracing::debug!(
job_id = %self.job_id,
event_type = %payload.event_type,
status = %r.status(),
"Job event POST rejected"
);
}
Err(e) => {
tracing::debug!(
job_id = %self.job_id,
event_type = %payload.event_type,
"Job event POST failed: {}", e
);
}
_ => {}
}
}
/// Poll the orchestrator for a follow-up prompt.
///
/// Returns `None` if no prompt is available (204 No Content).
pub async fn poll_prompt(&self) -> Result<Option<PromptResponse>, WorkerError> {
let resp = self
.client
.get(self.url("prompt"))
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| WorkerError::ConnectionFailed {
url: self.orchestrator_url.clone(),
reason: e.to_string(),
})?;
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !resp.status().is_success() {
return Err(WorkerError::OrchestratorRejected {
job_id: self.job_id,
reason: format!("prompt endpoint returned {}", resp.status()),
});
}
let prompt: PromptResponse =
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("failed to parse prompt response: {}", e),
})?;
Ok(Some(prompt))
}
/// Fetch credentials granted to this job from the orchestrator.
///
/// Returns an empty vec if no credentials are granted (204 No Content)
/// or if the endpoint returns 404. The caller should set each credential
/// as an environment variable before starting the execution loop.
pub async fn fetch_credentials(&self) -> Result<Vec<CredentialResponse>, WorkerError> {
let resp = self
.client
.get(self.url("credentials"))
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| WorkerError::ConnectionFailed {
url: self.orchestrator_url.clone(),
reason: e.to_string(),
})?;
// 204 or 404 means no credentials granted, not an error
if resp.status() == reqwest::StatusCode::NO_CONTENT
|| resp.status() == reqwest::StatusCode::NOT_FOUND
{
return Ok(vec![]);
}
if !resp.status().is_success() {
return Err(WorkerError::SecretResolveFailed {
secret_name: "(all)".to_string(),
reason: format!("credentials endpoint returned {}", resp.status()),
});
}
resp.json()
.await
.map_err(|e| WorkerError::SecretResolveFailed {
secret_name: "(all)".to_string(),
reason: format!("failed to parse credentials response: {}", e),
})
}
/// Signal job completion to the orchestrator.
pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> {
let _: serde_json::Value = self
.post_json("complete", report, "report complete")
.await?;
Ok(())
}
}
fn parse_finish_reason(s: &str) -> FinishReason {
match s {
"stop" => FinishReason::Stop,
"length" => FinishReason::Length,
"tool_use" | "tool_calls" => FinishReason::ToolUse,
"content_filter" => FinishReason::ContentFilter,
_ => FinishReason::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::TEST_BEARER_TOKEN;
#[test]
fn test_url_construction() {
let client = WorkerHttpClient::new(
"http://host.docker.internal:50051".to_string(),
Uuid::nil(),
TEST_BEARER_TOKEN.to_string(),
);
assert_eq!(
client.url("llm/complete"),
format!(
"http://host.docker.internal:50051/worker/{}/llm/complete",
Uuid::nil()
)
);
}
#[test]
fn test_parse_finish_reason() {
assert_eq!(parse_finish_reason("stop"), FinishReason::Stop);
assert_eq!(parse_finish_reason("tool_use"), FinishReason::ToolUse);
assert_eq!(parse_finish_reason("unknown"), FinishReason::Unknown);
}
#[test]
fn test_credentials_url_construction() {
let client = WorkerHttpClient::new(
"http://host.docker.internal:50051".to_string(),
Uuid::nil(),
TEST_BEARER_TOKEN.to_string(),
);
assert_eq!(
client.url("credentials"),
format!(
"http://host.docker.internal:50051/worker/{}/credentials",
Uuid::nil()
)
);
}
#[test]
fn test_job_description_deserialization() {
let json = r#"{"title":"Test","description":"desc","project_dir":null}"#;
let job: JobDescription = serde_json::from_str(json).unwrap();
assert_eq!(job.title, "Test");
assert_eq!(job.description, "desc");
assert!(job.project_dir.is_none());
}
}