Files
optimclaw/src/worker/claude_bridge.rs
T
cfb579a4bb feat: Add PR review tools, job monitor, and channel injection for E2E sandbox workflows (#57)
* feat: Add PR review tools, job monitor, and channel injection for E2E sandbox workflows

Adds JobEventsTool and JobPromptTool so the main agent can read container
event logs and send follow-up prompts to running Claude Code sessions.
A background JobMonitor forwards container assistant messages into the
agent loop via a new inject channel on ChannelManager.

CreateJobTool now accepts a project_dir parameter for mounting existing
cloned repos into containers, and spawns the monitor automatically for
async jobs.

Also: Dockerfile bumped to Rust 1.88 (rig-core needs let chains),
GITHUB_TOKEN forwarded into containers for gh CLI auth, and truncate()
fixed for multi-byte char boundary panics.

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

* fix: Address PR #57 review comments (IDOR, Dockerfile, truncate, logging)

- Add ownership checks to JobEventsTool and JobPromptTool via ContextManager
  to prevent users from accessing other users' jobs (IDOR)
- Combine Dockerfile gh CLI install into single apt-get layer
- Handle truncate() edge case when max falls inside first multi-byte char
- Log actual count of registered job management tools
- Document fire-and-forget job monitor lifecycle
- Add tests for ownership rejection and schema validation

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

* feat: Replace hardcoded GITHUB_TOKEN with on-demand credential delivery

Containers now fetch credentials via authenticated GET /worker/{id}/credentials
endpoint instead of receiving them baked into env vars at creation time. Secrets
are decrypted from SecretsStore on demand, scoped per-job via CredentialGrant,
and revoked automatically when the job completes.

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

* fix: Address sandbox audit findings (CONNECT tunnel, readonly_rootfs, type consolidation)

- Implement real CONNECT tunnel with bidirectional TCP piping via hyper upgrade
- Fix readonly_rootfs to apply for both ReadOnly and WorkspaceWrite policies
- Consolidate duplicate CredentialMapping/CredentialLocation into secrets::types
- Share reqwest::Client across proxy requests instead of per-request allocation
- Store Docker connection and reuse across executions
- Remove .unwrap() from proxy response builders with safe fallbacks
- Add output truncation to direct (non-container) execution (64KB limit)
- Delete dead src/tools/sandbox.rs (ToolSandbox never used)
- Fix connect_docker error message to list all attempted socket paths
- Update proxy credential injection to handle all CredentialLocation variants
- Use glob-based host_patterns matching for credential lookup in proxy policy

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

* fix: Address PR #57 review comments (IDOR, Dockerfile, truncate, logging)

- Dockerfile: install curl+ca-certificates before fetching GitHub CLI GPG key
- JobEventsTool/JobPromptTool: reject missing context (prevents IDOR bypass)
- parse_credentials: validate env var names against denylist and pattern
- resolve_project_dir: require explicit paths to exist before validation
- Credential serving: lower log level from info to debug

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

* fix: Address orchestrator audit findings (constant-time auth, error handling, tests)

- auth: constant-time token comparison via subtle::ConstantTimeEq
- auth: replace hand-rolled hex_encode with std::fmt::Write fold
- api: report_status now updates ContainerHandle (was a no-op)
- api: log complete_job errors instead of silently discarding
- job_manager: log Docker cleanup errors in stop_job/complete_job
- job_manager: extract validate_bind_mount_path with proper error on
  missing home_dir and mandatory base dir creation before canonicalize
- job_manager: cache Docker connection across operations
- error: remove dead OrchestratorError::AuthFailed and ContainerTimeout
- Add 13 new tests (prompt queue, credentials, events, status, paths)

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

* fix: use floor_char_boundary in sandbox manager truncate to prevent multi-byte panics

String::truncate() panics when the index falls mid-way through a
multi-byte UTF-8 character. Use the same floor_char_boundary utility
already used in worker/runtime.rs and tools/builtin/shell.rs.

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

* fix: default base_url to private.near.ai for Responses API mode

Session tokens only authenticate against private.near.ai, not
cloud-api.near.ai. The default base_url now matches the api_mode:
- Responses (session token): https://private.near.ai
- ChatCompletions (API key): https://cloud-api.near.ai

This broke when the multi-provider merge introduced cloud-api.near.ai
as the unconditional default.

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

* fix: use private.near.ai as default base URL for all API modes

private.near.ai now supports both Responses and ChatCompletions
endpoints, so there is no reason to route through cloud-api.near.ai.
This also fixes session token auth which only works against
private.near.ai.

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

* fix: harden libSQL concurrency, fix Claude Code Docker auth and permissions

Three fixes for the sandbox/Claude Code pipeline:

1. SQLite "database is locked": set WAL journal mode in migrations and
   PRAGMA busy_timeout=5000 on every connection across LibSqlBackend,
   LibSqlSecretsStore, and LibSqlWasmToolStore (~83 async call sites).

2. Claude Code container auth: extract OAuth token from macOS Keychain
   (or Linux ~/.claude/.credentials.json) at startup and inject via
   CLAUDE_CODE_OAUTH_TOKEN env var. Removes the broken bind-mount
   approach that failed on uid mismatch.

3. Claude Code tool permissions: wire CLAUDE_CODE_ALLOWED_TOOLS env var
   through to the worker binary (was hardcoded to empty vec), and expand
   defaults to include all standard tools (Read, Write, Edit, Glob, Grep,
   NotebookEdit, Bash, Task, WebFetch, WebSearch).

Also adds --verbose flag to claude CLI (required with stream-json + -p),
failover provider model switching, and nearai models endpoint fix.

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

* fix: stream event parsing, job ID prefix resolution, session renewal in list_models

Three fixes for the Docker/gateway pipeline:

1. Claude Code stream event parsing (claude_bridge.rs): Rewrite
   ClaudeStreamEvent to match actual NDJSON format where content blocks
   are nested under message.content[], not at the top level. Add handler
   for "user" events (tool_result blocks) and emit result text as a
   "message" event so reviews appear in gateway activity view.

2. Job ID prefix resolution (job.rs): Add resolve_job_id() that accepts
   short hex prefixes (like git short SHAs) in addition to full UUIDs.
   The LLM sees truncated IDs in job monitor messages like "[Job f2854dd8]"
   and can now use them directly with job_status/cancel/events/prompt tools.

3. Session renewal in list_models (nearai.rs): list_models() now retries
   with OAuth renewal on 401, matching send_request()'s existing behavior.
   Previously it returned SessionExpired immediately, causing the setup
   wizard to fall back to defaults instead of prompting re-authentication.

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

* fix: /model command now lists available models

Previously /model with no args only showed the current model name.
Now it fetches and displays all available models from the provider,
marking the active one, so users can see what's available before
switching with /model <name>.

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

* fix: address PR #57 review findings (set_var UB, tunnel timeout, restart creds)

- Replace unsafe `std::env::set_var` in worker runtime and Claude bridge
  with `Command::envs()` injection via a new `extra_env` field on
  `JobContext`, avoiding undefined behavior in the multi-threaded tokio
  runtime.
- Add 30-minute timeout to CONNECT tunnel `copy_bidirectional` in the
  sandbox proxy to prevent stuck connections from leaking spawned tasks.
- Persist credential grants (as JSON in the description column) on
  `SandboxJobRecord` so `jobs_restart_handler` can restore them instead
  of passing `vec![]`, which caused restarted containers to lose access
  to their original secrets.

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

* fix: address second round of PR #57 review comments

- Normalize host_patterns to lowercase in proxy policy matching
- Push LIMIT into SQL for list_job_events (Database trait + both backends)
- Remove unused was_explicit binding in job tool
- Return 500 instead of 200 in make_response fallback path
- Update copy_auth_from_mount docstring for env-var default
- Use entry.file_type() instead of is_dir() to avoid following symlinks

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

* fix: address third round of PR #57 review comments

- Restore glob patterns in default_claude_code_allowed_tools (Bash -> Bash(*))
- Add tracing::warn for credential grant serialize/deserialize failures
- Wrap extra_env in Arc<HashMap> to avoid deep cloning per tool call
- Document unsupported credential locations (AuthorizationBasic, UrlPath)
- Document TOCTOU window in validate_bind_mount_path
- Expand doc comments on JobEventsTool and JobPromptTool

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

* fix: address fourth round of PR #57 review comments

- Document CONNECT tunnel task lifecycle (timeout is the cleanup mechanism)
- Remove secret names from error-level credential logs to prevent leaking
- Expand DANGEROUS_ENV_VARS denylist with language runtime hijack vectors

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

* fix: address fifth round of PR #57 review comments

- Promote job monitor startup log to info level for observability
- Require minimum 4-char prefix in resolve_job_id to limit enumeration
- Cap credential grants at 20 per job to bound column storage
- Clamp job events limit to 1..1000 to prevent memory abuse

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

* fix: add missing closing brace for SkillsConfig impl block

The merge resolution dropped the closing `}` for `impl SkillsConfig`,
causing a compilation error in CI.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 00:48:43 +00:00

1031 lines
38 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 with these top-level types:
///
/// system -> session init (session_id, tools, model)
/// assistant -> LLM response, nested under message.content[] as text/tool_use blocks
/// user -> tool results, nested under message.content[] as tool_result blocks
/// result -> final summary (is_error, duration_ms, num_turns, result text)
///
/// Content blocks live under `message.content`, NOT at the top level.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeStreamEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub subtype: Option<String>,
/// For `assistant` and `user` events: the message wrapper containing content blocks.
#[serde(default)]
pub message: Option<MessageWrapper>,
/// For `result` events: the final text output.
#[serde(default)]
pub result: Option<serde_json::Value>,
/// For `result` events: whether the session ended in error.
#[serde(default)]
pub is_error: Option<bool>,
/// For `result` events: total wall-clock duration.
#[serde(default)]
pub duration_ms: Option<u64>,
/// For `result` events: number of agentic turns used.
#[serde(default)]
pub num_turns: Option<u32>,
}
/// Wrapper around the `message` field in assistant/user events.
///
/// ```text
/// { "type": "assistant", "message": { "content": [ { "type": "text", ... } ] } }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageWrapper {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub content: Option<Vec<ContentBlock>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentBlock {
#[serde(rename = "type")]
pub block_type: String,
/// Text block content.
#[serde(default)]
pub text: Option<String>,
/// Tool name (for tool_use blocks).
#[serde(default)]
pub name: Option<String>,
/// Tool use ID (for tool_use and tool_result blocks).
#[serde(default)]
pub id: Option<String>,
/// Tool input params (for tool_use blocks).
#[serde(default)]
pub input: Option<serde_json::Value>,
/// Tool result content (for tool_result blocks), or general content.
#[serde(default)]
pub content: Option<serde_json::Value>,
/// Tool use ID reference (for tool_result blocks).
#[serde(default)]
pub tool_use_id: Option<String>,
}
/// 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(())
}
/// Copy auth files from a read-only source into the writable home dir.
///
/// If the orchestrator bind-mounts the host's `~/.claude` at
/// `/home/sandbox/.claude-host:ro`, this copies everything into the
/// container's own `/home/sandbox/.claude` so Claude Code can read auth
/// credentials AND write its state (todos, debug files, etc.) without
/// touching the host filesystem.
///
/// When no host mount is present (the default orchestrator injects
/// credentials via environment variables), this is a no-op.
fn copy_auth_from_mount(&self) -> Result<(), WorkerError> {
let mount = std::path::Path::new("/home/sandbox/.claude-host");
if !mount.exists() {
return Ok(());
}
let target = std::path::Path::new("/home/sandbox/.claude");
std::fs::create_dir_all(target).map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to create ~/.claude: {e}"),
})?;
let copied =
copy_dir_recursive(mount, target).map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to copy auth from host mount: {e}"),
})?;
tracing::info!(
job_id = %self.config.job_id,
files_copied = copied,
"Copied auth config from host mount into container"
);
Ok(())
}
/// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups.
pub async fn run(&self) -> Result<(), WorkerError> {
// Copy auth files from read-only host mount (if present) into the
// writable home directory before Claude Code needs them.
self.copy_auth_from_mount()?;
// 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)
);
// Fetch credentials for injection into the spawned Command via .envs()
// (avoids unsafe std::env::set_var in multi-threaded runtime).
let credentials = self.client.fetch_credentials().await?;
let mut extra_env = std::collections::HashMap::new();
for cred in &credentials {
extra_env.insert(cred.env_var.clone(), cred.value.clone());
}
if !extra_env.is_empty() {
tracing::info!(
job_id = %self.config.job_id,
"Fetched {} credential(s) for child process injection",
extra_env.len()
);
}
// Warn if no auth method is available (check both process env and fetched credentials).
let has_api_key = extra_env.contains_key("ANTHROPIC_API_KEY")
|| std::env::var("ANTHROPIC_API_KEY").is_ok();
let has_oauth = extra_env.contains_key("CLAUDE_CODE_OAUTH_TOKEN")
|| std::env::var("CLAUDE_CODE_OAUTH_TOKEN").is_ok();
if !has_api_key && !has_oauth {
tracing::warn!(
job_id = %self.config.job_id,
"No Claude Code auth available. Set ANTHROPIC_API_KEY or run \
`claude login` on the host to authenticate."
);
}
// 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, &extra_env)
.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(), &extra_env)
.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>,
extra_env: &std::collections::HashMap<String, String>,
) -> Result<Option<String>, WorkerError> {
let mut cmd = Command::new("claude");
cmd.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.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);
}
// Inject credentials into the child process environment without
// mutating the global process env (which is unsafe in multi-threaded programs).
cmd.envs(extra_env);
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();
// Helper: extract content blocks from message wrapper.
let blocks = event.message.as_ref().and_then(|m| m.content.as_ref());
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" => {
// Content blocks are nested under message.content[].
if let Some(blocks) = blocks {
for block in blocks {
match block.block_type.as_str() {
"text" => {
if let Some(ref text) = block.text.as_deref().filter(|t| !t.is_empty())
{
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,
"tool_use_id": block.id,
"input": block.input,
}),
});
}
_ => {}
}
}
}
}
"user" => {
// User events carry tool_result blocks under message.content[].
if let Some(blocks) = blocks {
for block in blocks {
if block.block_type == "tool_result" {
payloads.push(JobEventPayload {
event_type: "tool_result".to_string(),
data: serde_json::json!({
"tool_use_id": block.tool_use_id,
"output": block.content,
}),
});
}
}
}
}
"result" => {
let is_error = event.is_error.unwrap_or(false);
// Emit the final review text as a message so it appears in activity.
if let Some(text) = event
.result
.as_ref()
.and_then(|v| v.as_str())
.filter(|t| !t.is_empty())
{
payloads.push(JobEventPayload {
event_type: "message".to_string(),
data: serde_json::json!({
"role": "assistant",
"content": text,
}),
});
}
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.duration_ms,
"num_turns": event.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
}
/// Recursively copy files and directories from `src` to `dst`, skipping
/// entries that can't be read (e.g. permission-restricted files owned by a
/// different uid on a read-only bind mount). Returns the number of files
/// successfully copied.
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<usize> {
let entries = match std::fs::read_dir(src) {
Ok(e) => e,
Err(e) => {
tracing::debug!("Skipping unreadable directory {}: {}", src.display(), e);
return Ok(0);
}
};
let mut copied = 0;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::debug!("Skipping unreadable entry in {}: {}", src.display(), e);
continue;
}
};
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(e) => {
tracing::debug!(
"Skipping entry with unreadable type {}: {}",
src_path.display(),
e
);
continue;
}
};
// Skip symlinks to avoid following links outside the mount.
if file_type.is_symlink() {
tracing::debug!("Skipping symlink {}", src_path.display());
continue;
}
if file_type.is_dir() {
if std::fs::create_dir_all(&dst_path).is_ok() {
copied += copy_dir_recursive(&src_path, &dst_path)?;
}
} else {
match std::fs::copy(&src_path, &dst_path) {
Ok(_) => copied += 1,
Err(e) => {
tracing::debug!("Skipping unreadable file {}: {}", src_path.display(), e);
}
}
}
}
Ok(copied)
}
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() {
// Real Claude Code format: content blocks are under message.content
let json = r#"{"type":"assistant","message":{"role":"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.message.unwrap().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","message":{"content":[{"type":"tool_use","id":"toolu_01abc","name":"Bash","input":{"command":"ls"}}]}}"#;
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
let blocks = event.message.unwrap().content.unwrap();
assert_eq!(blocks[0].block_type, "tool_use");
assert_eq!(blocks[0].name.as_deref(), Some("Bash"));
assert_eq!(blocks[0].id.as_deref(), Some("toolu_01abc"));
assert!(blocks[0].input.is_some());
}
#[test]
fn test_parse_user_tool_result_event() {
let json = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01abc","content":"/workspace"}]}}"#;
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event_type, "user");
let blocks = event.message.unwrap().content.unwrap();
assert_eq!(blocks[0].block_type, "tool_result");
assert_eq!(blocks[0].tool_use_id.as_deref(), Some("toolu_01abc"));
}
#[test]
fn test_parse_result_event() {
let json = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":5000,"num_turns":3,"result":"Done.","session_id":"sid-1"}"#;
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event_type, "result");
assert_eq!(event.is_error, Some(false));
assert_eq!(event.duration_ms, Some(5000));
assert_eq!(event.num_turns, Some(3));
assert_eq!(event.result.unwrap().as_str().unwrap(), "Done.");
}
#[test]
fn test_parse_result_error_event() {
let json = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"duration_ms":60000,"num_turns":50}"#;
let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.is_error, Some(true));
assert_eq!(event.subtype.as_deref(), Some("error_max_turns"));
}
#[test]
fn test_stream_event_to_payloads_system() {
let event = ClaudeStreamEvent {
event_type: "system".to_string(),
session_id: Some("sid-123".to_string()),
subtype: Some("init".to_string()),
message: None,
result: None,
is_error: None,
duration_ms: None,
num_turns: 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,
subtype: None,
message: Some(MessageWrapper {
role: Some("assistant".to_string()),
content: Some(vec![ContentBlock {
block_type: "text".to_string(),
text: Some("Here's the answer".to_string()),
name: None,
id: None,
input: None,
content: None,
tool_use_id: None,
}]),
}),
result: None,
is_error: None,
duration_ms: None,
num_turns: 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_assistant_tool_use() {
let event = ClaudeStreamEvent {
event_type: "assistant".to_string(),
session_id: None,
subtype: None,
message: Some(MessageWrapper {
role: Some("assistant".to_string()),
content: Some(vec![ContentBlock {
block_type: "tool_use".to_string(),
text: None,
name: Some("Bash".to_string()),
id: Some("toolu_01abc".to_string()),
input: Some(serde_json::json!({"command": "ls"})),
content: None,
tool_use_id: None,
}]),
}),
result: None,
is_error: None,
duration_ms: None,
num_turns: None,
};
let payloads = stream_event_to_payloads(&event);
assert_eq!(payloads.len(), 1);
assert_eq!(payloads[0].event_type, "tool_use");
assert_eq!(payloads[0].data["tool_name"], "Bash");
assert_eq!(payloads[0].data["tool_use_id"], "toolu_01abc");
}
#[test]
fn test_stream_event_to_payloads_user_tool_result() {
let event = ClaudeStreamEvent {
event_type: "user".to_string(),
session_id: None,
subtype: None,
message: Some(MessageWrapper {
role: Some("user".to_string()),
content: Some(vec![ContentBlock {
block_type: "tool_result".to_string(),
text: None,
name: None,
id: None,
input: None,
content: Some(serde_json::json!("/workspace")),
tool_use_id: Some("toolu_01abc".to_string()),
}]),
}),
result: None,
is_error: None,
duration_ms: None,
num_turns: None,
};
let payloads = stream_event_to_payloads(&event);
assert_eq!(payloads.len(), 1);
assert_eq!(payloads[0].event_type, "tool_result");
assert_eq!(payloads[0].data["tool_use_id"], "toolu_01abc");
assert_eq!(payloads[0].data["output"], "/workspace");
}
#[test]
fn test_stream_event_to_payloads_result_success() {
let event = ClaudeStreamEvent {
event_type: "result".to_string(),
session_id: Some("s1".to_string()),
subtype: Some("success".to_string()),
message: None,
result: Some(serde_json::json!("The review is complete.")),
is_error: Some(false),
duration_ms: Some(12000),
num_turns: Some(5),
};
let payloads = stream_event_to_payloads(&event);
// Should emit a message (the result text) + a result event
assert_eq!(payloads.len(), 2);
assert_eq!(payloads[0].event_type, "message");
assert_eq!(payloads[0].data["content"], "The review is complete.");
assert_eq!(payloads[1].event_type, "result");
assert_eq!(payloads[1].data["status"], "completed");
}
#[test]
fn test_stream_event_to_payloads_result_error() {
let event = ClaudeStreamEvent {
event_type: "result".to_string(),
session_id: None,
subtype: Some("error_max_turns".to_string()),
message: None,
result: None,
is_error: Some(true),
duration_ms: None,
num_turns: None,
};
let payloads = stream_event_to_payloads(&event);
assert_eq!(payloads.len(), 1);
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,
subtype: None,
message: None,
result: None,
is_error: None,
duration_ms: None,
num_turns: 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());
}
#[test]
fn test_copy_dir_recursive() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
// Create a nested structure in src
std::fs::write(src.path().join("auth.json"), r#"{"token":"abc"}"#).unwrap();
std::fs::create_dir_all(src.path().join("subdir")).unwrap();
std::fs::write(src.path().join("subdir").join("nested.txt"), "nested").unwrap();
let copied = copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(copied, 2);
// Verify files were copied
assert_eq!(
std::fs::read_to_string(dst.path().join("auth.json")).unwrap(),
r#"{"token":"abc"}"#
);
assert_eq!(
std::fs::read_to_string(dst.path().join("subdir").join("nested.txt")).unwrap(),
"nested"
);
}
#[test]
fn test_copy_dir_recursive_empty_source() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
let copied = copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(copied, 0);
}
#[test]
fn test_copy_dir_recursive_skips_nonexistent_source() {
let dst = tempfile::tempdir().unwrap();
let nonexistent = std::path::Path::new("/no/such/path");
// Should gracefully return 0 instead of failing
let copied = copy_dir_recursive(nonexistent, dst.path()).unwrap();
assert_eq!(copied, 0);
}
}