mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
* Bump MSRV to 1.92 and add GCP deployment files rig-core 0.30 uses let_chains (stabilized post-1.87), which breaks builds on Rust 1.85. Bump rust-version in Cargo.toml and both Dockerfiles to 1.92 (verified working). Add cloud deployment scaffolding: - Dockerfile: multi-stage build for the main agent container - deploy/cloud-sql-proxy.service: systemd unit for Cloud SQL Auth Proxy - deploy/ironclaw.service: systemd unit for the IronClaw container - deploy/setup.sh: VM bootstrap script (Docker, proxy, services) - deploy/env.example: reference environment configuration Co-Authored-By: Claude Opus 4.6 <[email protected]> * Address review feedback: harden deploy scaffolding - Add comment explaining GATEWAY_HOST=0.0.0.0 and when to use 127.0.0.1 - Document /opt/ironclaw ownership model (root-owned, Docker reads as root) - Switch cloud-sql-proxy service from User=root to DynamicUser=yes Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Resolve clippy lints (Rust 1.93) and fix CI test workflow - Fix 97 collapsible_if warnings using let-chains syntax (auto-fixed) - Fix ptr_arg: change &PathBuf to &Path in pairing store functions - Fix suspicious_open_options: add .truncate(false) to OpenOptions - Fix too_many_arguments: add clippy allow on execute_status - Fix unnecessary_unwrap: use if-let in repository.rs hybrid_search - Gate unused EchoTool with #[cfg(test)] - Add PairingStore argument to ChannelStoreData::new() test call sites - Add skip guard for bundled channel test when WASM artifacts unavailable - Split CI test workflow to exclude PostgreSQL-dependent integration tests Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address review feedback from ilblackdragon - Add root check to setup.sh (exits with error if not root) - Add warning comment to env.example about placeholder passwords - Dockerfile.worker already uses rust:1.92 (no change needed) - PR #41 overlap noted; will rebase after #41 merges Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: resolve 47 collapsible_if clippy warnings Collapse nested if statements across the codebase to satisfy clippy::collapsible_if on Rust 1.93. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
739 lines
26 KiB
Rust
739 lines
26 KiB
Rust
//! Claude Code bridge for sandboxed execution.
|
|
//!
|
|
//! Spawns the `claude` CLI inside a Docker container and streams its NDJSON
|
|
//! output back to the orchestrator via HTTP. Supports follow-up prompts via
|
|
//! `--resume`.
|
|
//!
|
|
//! Security model: the Docker container is the primary security boundary
|
|
//! (cap-drop ALL, non-root user, memory limits, network isolation).
|
|
//! As defense-in-depth, a project-level `.claude/settings.json` is written
|
|
//! before spawning with an explicit tool allowlist. Only listed tools are
|
|
//! auto-approved; unknown/future tools would require interactive approval,
|
|
//! which times out harmlessly in the non-interactive container.
|
|
//!
|
|
//! ```text
|
|
//! ┌──────────────────────────────────────────────┐
|
|
//! │ Docker Container │
|
|
//! │ │
|
|
//! │ ironclaw claude-bridge --job-id <uuid> │
|
|
//! │ └─ writes /workspace/.claude/settings.json │
|
|
//! │ └─ claude -p "task" --output-format │
|
|
//! │ stream-json │
|
|
//! │ └─ reads stdout line-by-line │
|
|
//! │ └─ POSTs events to orchestrator │
|
|
//! │ └─ polls for follow-up prompts │
|
|
//! │ └─ on follow-up: claude --resume │
|
|
//! └──────────────────────────────────────────────┘
|
|
//! ```
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
use tokio::process::Command;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::WorkerError;
|
|
use crate::worker::api::{CompletionReport, JobEventPayload, PromptResponse, WorkerHttpClient};
|
|
|
|
/// Configuration for the Claude bridge runtime.
|
|
pub struct ClaudeBridgeConfig {
|
|
pub job_id: Uuid,
|
|
pub orchestrator_url: String,
|
|
pub max_turns: u32,
|
|
pub model: String,
|
|
pub timeout: Duration,
|
|
/// Tool patterns to auto-approve via project-level settings.json.
|
|
pub allowed_tools: Vec<String>,
|
|
}
|
|
|
|
/// A Claude Code streaming event (NDJSON line from `--output-format stream-json`).
|
|
///
|
|
/// Claude Code emits one JSON object per line. We capture the key fields
|
|
/// we need and forward the rest as opaque data.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ClaudeStreamEvent {
|
|
#[serde(rename = "type")]
|
|
pub event_type: String,
|
|
|
|
/// For `system` events: the session ID.
|
|
#[serde(default)]
|
|
pub session_id: Option<String>,
|
|
|
|
/// For `assistant` events: the text content blocks.
|
|
#[serde(default)]
|
|
pub content: Option<Vec<ContentBlock>>,
|
|
|
|
/// For `result` events: final status info.
|
|
#[serde(default)]
|
|
pub result: Option<ResultInfo>,
|
|
|
|
/// For `tool_use`/`tool_result`: the tool name.
|
|
#[serde(default)]
|
|
pub tool_name: Option<String>,
|
|
|
|
/// For `tool_use`: the input parameters.
|
|
#[serde(default)]
|
|
pub input: Option<serde_json::Value>,
|
|
|
|
/// For `tool_result`: the output content.
|
|
#[serde(default)]
|
|
pub output: Option<String>,
|
|
|
|
/// Subtype discriminator (e.g. "text", "tool_use", "tool_result").
|
|
#[serde(default)]
|
|
pub subtype: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContentBlock {
|
|
#[serde(rename = "type")]
|
|
pub block_type: String,
|
|
#[serde(default)]
|
|
pub text: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub input: Option<serde_json::Value>,
|
|
#[serde(default)]
|
|
pub content: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ResultInfo {
|
|
#[serde(default)]
|
|
pub is_error: Option<bool>,
|
|
#[serde(default)]
|
|
pub duration_ms: Option<u64>,
|
|
#[serde(default)]
|
|
pub num_turns: Option<u32>,
|
|
}
|
|
|
|
/// The Claude Code bridge runtime.
|
|
pub struct ClaudeBridgeRuntime {
|
|
config: ClaudeBridgeConfig,
|
|
client: Arc<WorkerHttpClient>,
|
|
}
|
|
|
|
impl ClaudeBridgeRuntime {
|
|
/// Create a new bridge runtime.
|
|
///
|
|
/// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth.
|
|
pub fn new(config: ClaudeBridgeConfig) -> Result<Self, WorkerError> {
|
|
let client = Arc::new(WorkerHttpClient::from_env(
|
|
config.orchestrator_url.clone(),
|
|
config.job_id,
|
|
)?);
|
|
|
|
Ok(Self { config, client })
|
|
}
|
|
|
|
/// Write project-level `.claude/settings.json` with the tool allowlist.
|
|
///
|
|
/// This replaces `--dangerously-skip-permissions` with an explicit set of
|
|
/// auto-approved tools. The Docker container is still the primary security
|
|
/// boundary; this is defense-in-depth.
|
|
fn write_permission_settings(&self) -> Result<(), WorkerError> {
|
|
let settings_json = build_permission_settings(&self.config.allowed_tools);
|
|
let settings_dir = std::path::Path::new("/workspace/.claude");
|
|
std::fs::create_dir_all(settings_dir).map_err(|e| WorkerError::ExecutionFailed {
|
|
reason: format!("failed to create /workspace/.claude/: {e}"),
|
|
})?;
|
|
std::fs::write(settings_dir.join("settings.json"), &settings_json).map_err(|e| {
|
|
WorkerError::ExecutionFailed {
|
|
reason: format!("failed to write settings.json: {e}"),
|
|
}
|
|
})?;
|
|
tracing::info!(
|
|
job_id = %self.config.job_id,
|
|
tools = ?self.config.allowed_tools,
|
|
"Wrote Claude Code permission settings"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups.
|
|
pub async fn run(&self) -> Result<(), WorkerError> {
|
|
// Write project-level settings with explicit tool allowlist.
|
|
// This replaces --dangerously-skip-permissions with defense-in-depth:
|
|
// only the listed tools are auto-approved, unknown tools fail safely.
|
|
self.write_permission_settings()?;
|
|
|
|
// Fetch the job description from the orchestrator
|
|
let job = self.client.get_job().await?;
|
|
|
|
tracing::info!(
|
|
job_id = %self.config.job_id,
|
|
"Starting Claude Code bridge for: {}",
|
|
truncate(&job.description, 100)
|
|
);
|
|
|
|
// Report that we're running
|
|
self.client
|
|
.report_status(&crate::worker::api::StatusUpdate {
|
|
state: "running".to_string(),
|
|
message: Some("Spawning Claude Code".to_string()),
|
|
iteration: 0,
|
|
})
|
|
.await?;
|
|
|
|
// Run the initial Claude session
|
|
let session_id = match self.run_claude_session(&job.description, None).await {
|
|
Ok(sid) => sid,
|
|
Err(e) => {
|
|
tracing::error!(job_id = %self.config.job_id, "Claude session failed: {}", e);
|
|
self.client
|
|
.report_complete(&CompletionReport {
|
|
success: false,
|
|
message: Some(format!("Claude Code failed: {}", e)),
|
|
iterations: 1,
|
|
})
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// Follow-up loop: poll for prompts, resume Claude sessions
|
|
let mut iteration = 1u32;
|
|
loop {
|
|
// Poll for a follow-up prompt (2 second intervals)
|
|
match self.poll_for_prompt().await {
|
|
Ok(Some(prompt)) => {
|
|
if prompt.done {
|
|
tracing::info!(job_id = %self.config.job_id, "Orchestrator signaled done");
|
|
break;
|
|
}
|
|
iteration += 1;
|
|
tracing::info!(
|
|
job_id = %self.config.job_id,
|
|
"Got follow-up prompt, resuming session"
|
|
);
|
|
if let Err(e) = self
|
|
.run_claude_session(&prompt.content, session_id.as_deref())
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
job_id = %self.config.job_id,
|
|
"Follow-up Claude session failed: {}", e
|
|
);
|
|
// Don't fail the whole job on a follow-up error, just report it
|
|
self.report_event(
|
|
"status",
|
|
&serde_json::json!({
|
|
"message": format!("Follow-up session failed: {}", e),
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
// No prompt available, wait and poll again
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
job_id = %self.config.job_id,
|
|
"Prompt polling error: {}", e
|
|
);
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.client
|
|
.report_complete(&CompletionReport {
|
|
success: true,
|
|
message: Some("Claude Code session completed".to_string()),
|
|
iterations: iteration,
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Spawn a `claude` CLI process and stream its output.
|
|
///
|
|
/// Returns the session_id if captured from the `system` init message.
|
|
async fn run_claude_session(
|
|
&self,
|
|
prompt: &str,
|
|
resume_session_id: Option<&str>,
|
|
) -> Result<Option<String>, WorkerError> {
|
|
let mut cmd = Command::new("claude");
|
|
cmd.arg("-p")
|
|
.arg(prompt)
|
|
.arg("--output-format")
|
|
.arg("stream-json")
|
|
.arg("--max-turns")
|
|
.arg(self.config.max_turns.to_string())
|
|
.arg("--model")
|
|
.arg(&self.config.model);
|
|
|
|
if let Some(sid) = resume_session_id {
|
|
cmd.arg("--resume").arg(sid);
|
|
}
|
|
|
|
cmd.current_dir("/workspace")
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped());
|
|
|
|
let mut child = cmd.spawn().map_err(|e| WorkerError::ExecutionFailed {
|
|
reason: format!("failed to spawn claude: {}", e),
|
|
})?;
|
|
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| WorkerError::ExecutionFailed {
|
|
reason: "failed to capture claude stdout".to_string(),
|
|
})?;
|
|
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| WorkerError::ExecutionFailed {
|
|
reason: "failed to capture claude stderr".to_string(),
|
|
})?;
|
|
|
|
// Spawn stderr reader that forwards lines as log events
|
|
let client_for_stderr = Arc::clone(&self.client);
|
|
let job_id = self.config.job_id;
|
|
let stderr_handle = tokio::spawn(async move {
|
|
let reader = BufReader::new(stderr);
|
|
let mut lines = reader.lines();
|
|
while let Ok(Some(line)) = lines.next_line().await {
|
|
tracing::debug!(job_id = %job_id, "claude stderr: {}", line);
|
|
let payload = JobEventPayload {
|
|
event_type: "status".to_string(),
|
|
data: serde_json::json!({ "message": line }),
|
|
};
|
|
client_for_stderr.post_event(&payload).await;
|
|
}
|
|
});
|
|
|
|
// Read stdout NDJSON line by line
|
|
let reader = BufReader::new(stdout);
|
|
let mut lines = reader.lines();
|
|
let mut session_id: Option<String> = None;
|
|
|
|
while let Ok(Some(line)) = lines.next_line().await {
|
|
let line = line.trim().to_string();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
|
Ok(event) => {
|
|
// Capture session_id from system init
|
|
if event.event_type == "system"
|
|
&& let Some(ref sid) = event.session_id
|
|
{
|
|
session_id = Some(sid.clone());
|
|
tracing::info!(
|
|
job_id = %self.config.job_id,
|
|
session_id = %sid,
|
|
"Captured Claude session ID"
|
|
);
|
|
}
|
|
|
|
// Convert to our event payload and forward
|
|
let payloads = stream_event_to_payloads(&event);
|
|
for payload in payloads {
|
|
self.report_event(&payload.event_type, &payload.data).await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// Not valid JSON, forward as a status message
|
|
tracing::debug!(
|
|
job_id = %self.config.job_id,
|
|
"Non-JSON claude output: {} (parse error: {})", line, e
|
|
);
|
|
self.report_event("status", &serde_json::json!({ "message": line }))
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for the process to exit
|
|
let status = child
|
|
.wait()
|
|
.await
|
|
.map_err(|e| WorkerError::ExecutionFailed {
|
|
reason: format!("failed waiting for claude: {}", e),
|
|
})?;
|
|
|
|
// Wait for stderr reader to finish
|
|
let _ = stderr_handle.await;
|
|
|
|
if !status.success() {
|
|
let code = status.code().unwrap_or(-1);
|
|
tracing::warn!(
|
|
job_id = %self.config.job_id,
|
|
exit_code = code,
|
|
"Claude process exited with non-zero status"
|
|
);
|
|
|
|
// Report result event
|
|
self.report_event(
|
|
"result",
|
|
&serde_json::json!({
|
|
"status": "error",
|
|
"exit_code": code,
|
|
"session_id": session_id,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
return Err(WorkerError::ExecutionFailed {
|
|
reason: format!("claude exited with code {}", code),
|
|
});
|
|
}
|
|
|
|
// Report successful result
|
|
self.report_event(
|
|
"result",
|
|
&serde_json::json!({
|
|
"status": "completed",
|
|
"session_id": session_id,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
Ok(session_id)
|
|
}
|
|
|
|
/// Post a job event to the orchestrator.
|
|
async fn report_event(&self, event_type: &str, data: &serde_json::Value) {
|
|
let payload = JobEventPayload {
|
|
event_type: event_type.to_string(),
|
|
data: data.clone(),
|
|
};
|
|
self.client.post_event(&payload).await;
|
|
}
|
|
|
|
/// Poll the orchestrator for a follow-up prompt.
|
|
async fn poll_for_prompt(&self) -> Result<Option<PromptResponse>, WorkerError> {
|
|
self.client.poll_prompt().await
|
|
}
|
|
}
|
|
|
|
/// Build the JSON content for `.claude/settings.json` with the given tool allowlist.
|
|
///
|
|
/// Produces a Claude Code project settings file that auto-approves the listed
|
|
/// tools while leaving any unknown/future tools unapproved (defense-in-depth).
|
|
fn build_permission_settings(allowed_tools: &[String]) -> String {
|
|
let settings = serde_json::json!({
|
|
"permissions": {
|
|
"allow": allowed_tools,
|
|
}
|
|
});
|
|
serde_json::to_string_pretty(&settings).expect("static JSON structure is always valid")
|
|
}
|
|
|
|
/// Convert a Claude stream event into one or more event payloads for the orchestrator.
|
|
fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec<JobEventPayload> {
|
|
let mut payloads = Vec::new();
|
|
|
|
match event.event_type.as_str() {
|
|
"system" => {
|
|
payloads.push(JobEventPayload {
|
|
event_type: "status".to_string(),
|
|
data: serde_json::json!({
|
|
"message": "Claude Code session started",
|
|
"session_id": event.session_id,
|
|
}),
|
|
});
|
|
}
|
|
"assistant" => {
|
|
// Extract text content and tool_use blocks
|
|
if let Some(ref blocks) = event.content {
|
|
for block in blocks {
|
|
match block.block_type.as_str() {
|
|
"text" => {
|
|
if let Some(ref text) = block.text {
|
|
payloads.push(JobEventPayload {
|
|
event_type: "message".to_string(),
|
|
data: serde_json::json!({
|
|
"role": "assistant",
|
|
"content": text,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
"tool_use" => {
|
|
payloads.push(JobEventPayload {
|
|
event_type: "tool_use".to_string(),
|
|
data: serde_json::json!({
|
|
"tool_name": block.name,
|
|
"input": block.input,
|
|
}),
|
|
});
|
|
}
|
|
"tool_result" => {
|
|
payloads.push(JobEventPayload {
|
|
event_type: "tool_result".to_string(),
|
|
data: serde_json::json!({
|
|
"tool_name": block.name.as_deref().unwrap_or("unknown"),
|
|
"output": block.content.as_deref().unwrap_or(""),
|
|
}),
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"result" => {
|
|
let is_error = event
|
|
.result
|
|
.as_ref()
|
|
.and_then(|r| r.is_error)
|
|
.unwrap_or(false);
|
|
payloads.push(JobEventPayload {
|
|
event_type: "result".to_string(),
|
|
data: serde_json::json!({
|
|
"status": if is_error { "error" } else { "completed" },
|
|
"session_id": event.session_id,
|
|
"duration_ms": event.result.as_ref().and_then(|r| r.duration_ms),
|
|
"num_turns": event.result.as_ref().and_then(|r| r.num_turns),
|
|
}),
|
|
});
|
|
}
|
|
_ => {
|
|
// Forward unknown event types as status
|
|
payloads.push(JobEventPayload {
|
|
event_type: "status".to_string(),
|
|
data: serde_json::json!({
|
|
"message": format!("Claude event: {}", event.event_type),
|
|
"raw_type": event.event_type,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
payloads
|
|
}
|
|
|
|
fn truncate(s: &str, max_len: usize) -> &str {
|
|
if s.len() <= max_len {
|
|
s
|
|
} else {
|
|
// Walk back from max_len to find a valid UTF-8 char boundary.
|
|
let mut end = max_len;
|
|
while end > 0 && !s.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
&s[..end]
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_system_event() {
|
|
let json = r#"{"type":"system","session_id":"abc-123","subtype":"init"}"#;
|
|
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
|
|
assert_eq!(event.event_type, "system");
|
|
assert_eq!(event.session_id.as_deref(), Some("abc-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_assistant_text_event() {
|
|
let json = r#"{"type":"assistant","content":[{"type":"text","text":"Hello world"}]}"#;
|
|
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
|
|
assert_eq!(event.event_type, "assistant");
|
|
let blocks = event.content.unwrap();
|
|
assert_eq!(blocks.len(), 1);
|
|
assert_eq!(blocks[0].block_type, "text");
|
|
assert_eq!(blocks[0].text.as_deref(), Some("Hello world"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_assistant_tool_use_event() {
|
|
let json = r#"{"type":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}"#;
|
|
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
|
|
let blocks = event.content.unwrap();
|
|
assert_eq!(blocks[0].block_type, "tool_use");
|
|
assert_eq!(blocks[0].name.as_deref(), Some("Bash"));
|
|
assert!(blocks[0].input.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_result_event() {
|
|
let json =
|
|
r#"{"type":"result","result":{"is_error":false,"duration_ms":5000,"num_turns":3}}"#;
|
|
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
|
|
assert_eq!(event.event_type, "result");
|
|
let result = event.result.unwrap();
|
|
assert_eq!(result.is_error, Some(false));
|
|
assert_eq!(result.duration_ms, Some(5000));
|
|
assert_eq!(result.num_turns, Some(3));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_result_error_event() {
|
|
let json = r#"{"type":"result","result":{"is_error":true}}"#;
|
|
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
|
|
let result = event.result.unwrap();
|
|
assert_eq!(result.is_error, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stream_event_to_payloads_system() {
|
|
let event = ClaudeStreamEvent {
|
|
event_type: "system".to_string(),
|
|
session_id: Some("sid-123".to_string()),
|
|
content: None,
|
|
result: None,
|
|
tool_name: None,
|
|
input: None,
|
|
output: None,
|
|
subtype: None,
|
|
};
|
|
let payloads = stream_event_to_payloads(&event);
|
|
assert_eq!(payloads.len(), 1);
|
|
assert_eq!(payloads[0].event_type, "status");
|
|
assert_eq!(payloads[0].data["session_id"], "sid-123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_stream_event_to_payloads_assistant_text() {
|
|
let event = ClaudeStreamEvent {
|
|
event_type: "assistant".to_string(),
|
|
session_id: None,
|
|
content: Some(vec![ContentBlock {
|
|
block_type: "text".to_string(),
|
|
text: Some("Here's the answer".to_string()),
|
|
name: None,
|
|
input: None,
|
|
content: None,
|
|
}]),
|
|
result: None,
|
|
tool_name: None,
|
|
input: None,
|
|
output: None,
|
|
subtype: None,
|
|
};
|
|
let payloads = stream_event_to_payloads(&event);
|
|
assert_eq!(payloads.len(), 1);
|
|
assert_eq!(payloads[0].event_type, "message");
|
|
assert_eq!(payloads[0].data["role"], "assistant");
|
|
assert_eq!(payloads[0].data["content"], "Here's the answer");
|
|
}
|
|
|
|
#[test]
|
|
fn test_stream_event_to_payloads_result_success() {
|
|
let event = ClaudeStreamEvent {
|
|
event_type: "result".to_string(),
|
|
session_id: Some("s1".to_string()),
|
|
content: None,
|
|
result: Some(ResultInfo {
|
|
is_error: Some(false),
|
|
duration_ms: Some(12000),
|
|
num_turns: Some(5),
|
|
}),
|
|
tool_name: None,
|
|
input: None,
|
|
output: None,
|
|
subtype: None,
|
|
};
|
|
let payloads = stream_event_to_payloads(&event);
|
|
assert_eq!(payloads.len(), 1);
|
|
assert_eq!(payloads[0].event_type, "result");
|
|
assert_eq!(payloads[0].data["status"], "completed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_stream_event_to_payloads_result_error() {
|
|
let event = ClaudeStreamEvent {
|
|
event_type: "result".to_string(),
|
|
session_id: None,
|
|
content: None,
|
|
result: Some(ResultInfo {
|
|
is_error: Some(true),
|
|
duration_ms: None,
|
|
num_turns: None,
|
|
}),
|
|
tool_name: None,
|
|
input: None,
|
|
output: None,
|
|
subtype: None,
|
|
};
|
|
let payloads = stream_event_to_payloads(&event);
|
|
assert_eq!(payloads[0].data["status"], "error");
|
|
}
|
|
|
|
#[test]
|
|
fn test_stream_event_to_payloads_unknown_type() {
|
|
let event = ClaudeStreamEvent {
|
|
event_type: "fancy_new_thing".to_string(),
|
|
session_id: None,
|
|
content: None,
|
|
result: None,
|
|
tool_name: None,
|
|
input: None,
|
|
output: None,
|
|
subtype: None,
|
|
};
|
|
let payloads = stream_event_to_payloads(&event);
|
|
assert_eq!(payloads.len(), 1);
|
|
assert_eq!(payloads[0].event_type, "status");
|
|
}
|
|
|
|
#[test]
|
|
fn test_claude_event_payload_serde() {
|
|
let payload = JobEventPayload {
|
|
event_type: "message".to_string(),
|
|
data: serde_json::json!({ "role": "assistant", "content": "hi" }),
|
|
};
|
|
let json = serde_json::to_string(&payload).unwrap();
|
|
let parsed: JobEventPayload = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.event_type, "message");
|
|
assert_eq!(parsed.data["content"], "hi");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate() {
|
|
assert_eq!(truncate("hello", 10), "hello");
|
|
assert_eq!(truncate("hello world", 5), "hello");
|
|
assert_eq!(truncate("", 5), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_permission_settings_default_tools() {
|
|
let tools: Vec<String> = ["Bash(*)", "Read", "Edit(*)", "Glob", "Grep"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
let json_str = build_permission_settings(&tools);
|
|
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
|
let allow = parsed["permissions"]["allow"].as_array().unwrap();
|
|
assert_eq!(allow.len(), 5);
|
|
assert_eq!(allow[0], "Bash(*)");
|
|
assert_eq!(allow[1], "Read");
|
|
assert_eq!(allow[2], "Edit(*)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_permission_settings_empty_tools() {
|
|
let json_str = build_permission_settings(&[]);
|
|
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
|
let allow = parsed["permissions"]["allow"].as_array().unwrap();
|
|
assert!(allow.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_permission_settings_is_valid_json() {
|
|
let tools = vec!["Bash(npm run *)".to_string(), "Read".to_string()];
|
|
let json_str = build_permission_settings(&tools);
|
|
// Must be valid JSON
|
|
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
|
// Must have the expected structure
|
|
assert!(parsed["permissions"].is_object());
|
|
assert!(parsed["permissions"]["allow"].is_array());
|
|
}
|
|
}
|