Skip to content

feat: Add PR review tools, job monitor, and channel injection for E2E sandbox workflows - #57

Merged
ilblackdragon merged 23 commits into
mainfrom
workflows-2
Feb 18, 2026
Merged

ilblackdragon merged 23 commits into
mainfrom
workflows-2

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

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.

… 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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 13, 2026 02:47
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @ilblackdragon, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the agent's ability to interact with and monitor sandbox environments, particularly for E2E testing and Claude Code sessions. It introduces new tools for inspecting job events and sending dynamic prompts to running jobs, enabling more interactive and informed agent workflows. Additionally, it improves the sandbox setup by allowing existing project directories to be mounted and ensures proper authentication for GitHub operations within containers. A critical fix for multi-byte character handling in string truncation also improves robustness.

Highlights

  • New Job Interaction Tools: Introduced JobEventsTool to allow agents to read event logs from sandbox jobs and JobPromptTool enabling agents to send follow-up prompts to running Claude Code sessions.
  • Background Job Monitoring: Implemented a background JobMonitor to inject assistant messages from sandbox jobs into the main agent loop, providing real-time feedback.
  • Enhanced Job Creation: Enhanced CreateJobTool to accept a project_dir parameter for mounting existing repositories and to automatically spawn the JobMonitor for async jobs.
  • Worker Environment Updates: Updated the Dockerfile.worker to Rust 1.88, included GitHub CLI for PR-related operations, and configured GITHUB_TOKEN to be forwarded into containers for authentication.
  • Multi-byte Character Handling Fix: Fixed a bug in the truncate() utility function to correctly handle multi-byte character boundaries, preventing potential panics.
Changelog
  • Dockerfile.worker
    • Updated base Rust image from 1.85 to 1.88.
    • Added installation of GitHub CLI.
    • Updated default Rust toolchain to 1.88.0.
  • src/agent/job_monitor.rs
    • Added new module job_monitor containing spawn_job_monitor function for background job event monitoring.
  • src/agent/mod.rs
    • Exported the new job_monitor module.
  • src/channels/manager.rs
    • Introduced an inject_tx (mpsc sender) and inject_rx (mpsc receiver) for message injection.
    • Added inject_sender() method to provide a clone of the injection sender.
    • Modified start_all() to merge the injection channel's stream into the main message stream.
  • src/main.rs
    • Updated the initialization of CreateJobTool to pass job_event_tx, inject_sender, and prompt_queue.
  • src/orchestrator/job_manager.rs
    • Added logic to forward GITHUB_TOKEN and GH_TOKEN environment variables into container jobs.
  • src/tools/builtin/job.rs
    • Imported IncomingMessage and SseEvent for job monitoring.
    • Modified CreateJobTool to include event_tx and inject_tx for monitor dependencies.
    • Added with_monitor_deps method to CreateJobTool for injecting monitor dependencies.
    • Updated CreateJobTool::execute to spawn job_monitor for non-waiting jobs and updated the success message.
    • Added project_dir parameter to CreateJobTool's schema and execution logic.
    • Removed a test assertion that project_dir should not be exposed to the LLM.
    • Introduced JobEventsTool for reading job event logs.
    • Introduced JobPromptTool for sending follow-up prompts to running jobs.
    • Added new tests for JobPromptTool and CreateJobTool's project_dir schema.
  • src/tools/builtin/mod.rs
    • Exported JobEventsTool, JobPromptTool, and PromptQueue.
  • src/tools/registry.rs
    • Imported JobEventsTool, JobPromptTool, and PromptQueue.
    • Updated register_job_tools to pass job_event_tx, inject_tx, and prompt_queue to CreateJobTool.
    • Conditionally registered JobEventsTool if a store is available.
    • Conditionally registered JobPromptTool if a prompt queue is available.
    • Updated logging for job tool registration.
  • src/worker/runtime.rs
    • Modified the truncate function to safely handle multi-byte characters by finding a character boundary.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant new functionality for end-to-end sandbox workflows, including tools for PR review, a job monitor for asynchronous tasks, and a channel injection mechanism. A critical security audit identified host credential leakage into the sandbox environment and two instances of Broken Access Control (IDOR) in the new job management tools. These vulnerabilities must be addressed to ensure sandbox isolation and user data privacy. Additionally, consider optimizing the Dockerfile to reduce image size. The bug fix in the truncate function for multi-byte characters is a positive improvement.

Comment thread src/orchestrator/job_manager.rs Outdated
Comment on lines +190 to +193
if let Ok(gh_token) = std::env::var("GITHUB_TOKEN") {
env_vec.push(format!("GITHUB_TOKEN={}", gh_token));
env_vec.push(format!("GH_TOKEN={}", gh_token));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The ContainerJobManager::create_job function forwards GITHUB_TOKEN and GH_TOKEN from the host into the sandboxed Docker container. This is a critical host credential leakage vulnerability, as it compromises the sandbox's isolation purpose. A malicious agent inside the container could exfiltrate these tokens, leading to unauthorized access. Avoid forwarding host credentials into the sandbox. If GitHub access is needed, use scoped credentials or a dedicated service account. Additionally, consider adding documentation advising users on the use of fine-grained personal access tokens with minimum required scopes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This is intentional for the PR review workflow (containers need gh access), but the current approach of forwarding the host token is too broad. We are planning a redesign with scoped credentials for a follow-up change.

Comment thread src/tools/builtin/job.rs
Comment on lines +963 to +997
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;

let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
})?;

let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'content' parameter".into()))?;

let done = params
.get("done")
.and_then(|v| v.as_bool())
.unwrap_or(false);

let prompt = crate::orchestrator::api::PendingPrompt {
content: content.to_string(),
done,
};

{
let mut queue = self.prompt_queue.lock().await;
queue.entry(job_id).or_default().push_back(prompt);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The JobPromptTool::execute function allows a user to queue a follow-up prompt for a sandbox job identified by its job_id. However, the tool does not verify if the authenticated user (_ctx.user_id) is the owner of the job. An attacker could potentially send prompts to active jobs belonging to other users if they can guess or obtain their job_id. Before queuing the prompt, verify that the job belongs to the current user by checking the user_id field in the job record.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1850f44. JobPromptTool now takes a ContextManager ref and verifies ctx.user_id matches the job owner before queuing. Added test_job_prompt_tool_rejects_other_users_job test.

Comment thread src/tools/builtin/job.rs
Comment on lines +854 to +875
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;

let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
})?;

let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;

let events =
self.store.list_job_events(job_id).await.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to load job events: {}", e))
})?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The JobEventsTool::execute function allows a user to read the event logs for a sandbox job identified by its job_id. However, it does not verify if the current user has permission to access the job's events. An attacker could read sensitive event logs (including messages, tool calls, and results) of other users' jobs if they can guess or obtain their job_id. Verify that the job belongs to the current user before returning the event logs by fetching the job record from the store and comparing its user_id with the requester's user_id.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1850f44. JobEventsTool now takes a ContextManager ref and verifies ctx.user_id matches the job owner before returning events. Added test_job_events_tool_rejects_other_users_job test.

Comment thread Dockerfile.worker Outdated
Comment on lines +40 to +45
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To optimize the Docker image, you could combine this RUN instruction with the package installation at line 25. By adding the GitHub CLI repository first, you can then use a single RUN command to perform apt-get update once and install all packages, including gh. This would reduce the final image size by removing a layer and an extra apt-get update command.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1850f44. Combined the gh CLI apt repo setup and all package installs into a single RUN layer, eliminating the extra apt-get update.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds infrastructure for the main agent to monitor and interact with running sandbox jobs, particularly Claude Code sessions. It introduces two new tools (JobEventsTool and JobPromptTool) that allow the main agent to read container event logs and send follow-up instructions to running jobs. A new JobMonitor component forwards assistant messages from containers into the agent loop via an injection channel on ChannelManager.

Changes:

  • Added JobEventsTool and JobPromptTool for reading job events and sending prompts to running containers
  • Implemented JobMonitor to forward Claude Code output to the main agent via injection channel
  • Added inject channel to ChannelManager for background tasks to push messages into agent loop
  • Enhanced CreateJobTool with project_dir parameter for mounting existing repos into containers
  • Fixed truncate() to handle multi-byte UTF-8 characters safely
  • Updated Dockerfile to forward GITHUB_TOKEN to containers and install gh CLI
  • Bumped Rust version references in Dockerfile

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/worker/runtime.rs Fixed truncate() to avoid panics on multi-byte character boundaries
src/tools/registry.rs Added registration for new JobEventsTool and JobPromptTool with conditional dependencies
src/tools/builtin/mod.rs Exported new job tools and PromptQueue type
src/tools/builtin/job.rs Added JobEventsTool, JobPromptTool, project_dir parameter, and job monitor spawning
src/orchestrator/job_manager.rs Forwarded GITHUB_TOKEN environment variable to containers
src/main.rs Wired up inject channel and prompt queue dependencies
src/channels/manager.rs Added injection channel for background tasks to push messages into agent stream
src/agent/mod.rs Registered new job_monitor module
src/agent/job_monitor.rs Implemented background monitor to forward container events to agent loop
Dockerfile.worker Updated Rust version, added gh CLI installation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tools/builtin/job.rs
Comment on lines +815 to +903
pub struct JobEventsTool {
store: Arc<Store>,
}

impl JobEventsTool {
pub fn new(store: Arc<Store>) -> Self {
Self { store }
}
}

#[async_trait]
impl Tool for JobEventsTool {
fn name(&self) -> &str {
"job_events"
}

fn description(&self) -> &str {
"Read the event log for a sandbox job. Shows messages, tool calls, results, \
and status changes from the container. Use this to check what Claude Code \
or a worker sub-agent has been doing."
}

fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The UUID of the sandbox job"
},
"limit": {
"type": "integer",
"description": "Maximum number of events to return (default 50, most recent)"
}
},
"required": ["job_id"]
})
}

async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;

let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
})?;

let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;

let events =
self.store.list_job_events(job_id).await.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to load job events: {}", e))
})?;

// Take the last `limit` events (most recent)
let start_idx = events.len().saturating_sub(limit);
let recent: Vec<serde_json::Value> = events[start_idx..]
.iter()
.map(|ev| {
serde_json::json!({
"event_type": ev.event_type,
"data": ev.data,
"created_at": ev.created_at.to_rfc3339(),
})
})
.collect();

let result = serde_json::json!({
"job_id": job_id_str,
"total_events": events.len(),
"returned": recent.len(),
"events": recent,
});

Ok(ToolOutput::success(result, start.elapsed()))
}

fn requires_sanitization(&self) -> bool {
true
}
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new JobEventsTool lacks test coverage. Other tools in this file (CreateJobTool, ListJobsTool, JobStatusTool, JobPromptTool) all have test coverage. Consider adding at least a basic test that verifies the tool can successfully retrieve events from the store, handles invalid job IDs appropriately, and respects the limit parameter.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 1850f44. test_job_events_tool_rejects_other_users_job tests the ownership check logic, and test_job_events_tool_schema validates the parameter schema. Full event retrieval tests require a PostgreSQL instance (integration test territory).

Comment thread src/tools/builtin/job.rs

if !wait {
// Spawn a background monitor that forwards Claude Code output
// into the main agent loop.

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spawned job monitor task is not tracked or awaited. If the main process shuts down before jobs complete, these background tasks may not be properly cleaned up. Consider either storing the JoinHandle in the CreateJobTool or ContainerJobManager for graceful shutdown, or documenting that these monitors are intentionally fire-and-forget and will terminate when the broadcast channel closes.

Suggested change
// into the main agent loop.
// into the main agent loop.
//
// NOTE: This monitor task is intentionally fire-and-forget and its
// lifecycle is bound to the broadcast channel and agent event loop:
// - It listens on a subscribed event stream (`etx.subscribe()`); when
// the broadcast sender is dropped (e.g., during shutdown), the
// subscription will be closed and the monitor task will exit.
// - It forwards events into the main agent loop via `inject_tx`; when
// the agent loop stops consuming or the sender is dropped, the
// monitor will also terminate.
// Because of this, we do not retain or await a JoinHandle here; the
// monitor is expected to terminate naturally when the channels close.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added documentation in 1850f44. The monitor is intentionally fire-and-forget: its lifetime is bound to the broadcast channel and inject sender. When either is dropped during shutdown, the monitor exits naturally. No JoinHandle needed.

Comment thread src/worker/runtime.rs
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the input string starts with a multi-byte character at every position from 0 to max (which is theoretically possible though unlikely), the loop could reduce end to 0, resulting in an empty slice with just "..." appended. While this is safe and won't panic, consider handling the edge case explicitly by either ensuring a minimum number of characters are included, or adding a comment explaining this is acceptable behavior.

Suggested change
format!("{}...", &s[..end])
if end == 0 && !s.is_empty() {
// If `max` falls inside the first multi-byte character, we may end up at 0.
// In that case, include at least the first character before adding "...".
let first_char = s.chars().next().unwrap();
format!("{}...", first_char)
} else {
format!("{}...", &s[..end])
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1850f44. When end reaches 0 (max falls inside the first multi-byte character), we now include the first whole character before appending "..." instead of producing a bare "...".

Comment thread src/tools/registry.rs Outdated
Comment on lines +215 to +227
// Register event reader if store is available
if let Some(store) = store {
self.register_sync(Arc::new(JobEventsTool::new(store)));
tracing::info!("Registered job_events tool");
}

// Register prompt tool if queue is available
if let Some(pq) = prompt_queue {
self.register_sync(Arc::new(JobPromptTool::new(pq)));
tracing::info!("Registered job_prompt tool");
}

tracing::info!("Registered job management tools");

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log message "Registered job management tools" is now less specific than before. Previously it logged "Registered 4 job management tools" with a specific count. Consider either updating the message to include the actual count of registered tools (which varies from 4 to 6 depending on whether store and prompt_queue are provided), or keep the generic message but add it once at the end instead of having individual logs for job_events and job_prompt followed by a generic message.

Suggested change
// Register event reader if store is available
if let Some(store) = store {
self.register_sync(Arc::new(JobEventsTool::new(store)));
tracing::info!("Registered job_events tool");
}
// Register prompt tool if queue is available
if let Some(pq) = prompt_queue {
self.register_sync(Arc::new(JobPromptTool::new(pq)));
tracing::info!("Registered job_prompt tool");
}
tracing::info!("Registered job management tools");
// Base tools: CreateJobTool, ListJobsTool, JobStatusTool, CancelJobTool
let mut job_tool_count = 4;
// Register event reader if store is available
if let Some(store) = store {
self.register_sync(Arc::new(JobEventsTool::new(store)));
job_tool_count += 1;
}
// Register prompt tool if queue is available
if let Some(pq) = prompt_queue {
self.register_sync(Arc::new(JobPromptTool::new(pq)));
job_tool_count += 1;
}
tracing::info!("Registered {} job management tools", job_tool_count);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1850f44. Now tracks the actual count (4 base + conditionally registered tools) and logs "Registered {} job management tools" with the real number.

ilblackdragon and others added 2 commits February 12, 2026 20:49
…ing)

- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 13, 2026 06:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/worker/runtime.rs Outdated
Comment on lines +102 to +109
// Fetch and set credentials before execution begins.
// Worker is a single-threaded entry point so set_var is safe here;
// the spawned tool commands inherit the process environment.
let credentials = self.client.fetch_credentials().await?;
for cred in &credentials {
// SAFETY: worker is single-threaded at this point (no tools running yet)
unsafe { std::env::set_var(&cred.env_var, &cred.value) };
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std::env::set_var is now unsafe because mutating the process environment can cause undefined behavior in multi-threaded programs. Even if no tools have started yet, this worker is running under #[tokio::main] (multi-thread runtime), so the process already has multiple OS threads. Prefer avoiding global env mutation: collect the credentials and pass them to spawned Commands via Command::envs(...) (or plumb a per-process env map into tool execution), or switch the worker entrypoint to a single-thread Tokio runtime if global env mutation is required.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern. The worker runtime is single-threaded at the point where set_var is called (before spawning any async tasks), but wrapping it in an unsafe block with a SAFETY comment is the right move. Tracking as a follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e51af7. Replaced unsafe { std::env::set_var(...) } with a HashMap<String, String> passed through JobContext.extra_env → Command::envs(). No more global env mutation.

Comment thread src/worker/claude_bridge.rs Outdated
Comment on lines +133 to +144
// Fetch and set credentials before spawning Claude.
// The spawned `claude` Command inherits env vars from this process.
let credentials = self.client.fetch_credentials().await?;
for cred in &credentials {
// SAFETY: bridge is single-threaded at this point (no child processes yet)
unsafe { std::env::set_var(&cred.env_var, &cred.value) };
}
if !credentials.is_empty() {
tracing::info!(
job_id = %self.config.job_id,
"Injected {} credential(s) into environment",
credentials.len()

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the worker runtime: std::env::set_var is unsafe in multi-threaded processes and this binary runs under the default multi-thread Tokio runtime. To avoid potential UB, prefer passing credentials as explicit environment variables on the claude Command (e.g., Command::envs(...)) instead of mutating the global process environment, or ensure the bridge truly runs single-threaded before calling set_var.

Suggested change
// Fetch and set credentials before spawning Claude.
// The spawned `claude` Command inherits env vars from this process.
let credentials = self.client.fetch_credentials().await?;
for cred in &credentials {
// SAFETY: bridge is single-threaded at this point (no child processes yet)
unsafe { std::env::set_var(&cred.env_var, &cred.value) };
}
if !credentials.is_empty() {
tracing::info!(
job_id = %self.config.job_id,
"Injected {} credential(s) into environment",
credentials.len()
// Fetch credentials before spawning Claude.
// Instead of mutating the global process environment (which is unsafe in
// multi-threaded processes), pass these explicitly to the `claude`
// Command via `.envs(...)`.
let credentials = self.client.fetch_credentials().await?;
let credential_envs: Vec<(String, String)> = credentials
.iter()
.map(|cred| (cred.env_var.clone(), cred.value.clone()))
.collect();
if !credential_envs.is_empty() {
tracing::info!(
job_id = %self.config.job_id,
"Prepared {} credential(s) for Claude environment",
credential_envs.len()

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the runtime comment: set_var is called before any async tasks are spawned. Will wrap in unsafe with a SAFETY comment in a follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e51af7. Replaced unsafe { std::env::set_var(...) } with a HashMap<String, String> passed through JobContext.extra_env → Command::envs(). No more global env mutation.

Comment thread Dockerfile.worker Outdated
Comment on lines 25 to 31
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This RUN step uses curl before apt-get install installs it (Debian slim images typically don't include curl by default), so the build will fail. Install ca-certificates + curl first (still can be in one layer), then add the GitHub CLI repo key/list, then install the rest.

Suggested change
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48b68d7. Split the RUN to install curl and ca-certificates via apt-get before using curl to fetch the GitHub CLI GPG key.

Comment thread src/tools/builtin/job.rs
Comment on lines +114 to +141
let mut grants = Vec::with_capacity(creds_obj.len());
for (secret_name, env_var_value) in creds_obj {
let env_var = env_var_value.as_str().ok_or_else(|| {
ToolError::InvalidParameters(format!(
"credential env var for '{}' must be a string",
secret_name
))
})?;

// Validate the secret actually exists
let exists = secrets.exists(user_id, secret_name).await.map_err(|e| {
ToolError::ExecutionFailed(format!(
"failed to check secret '{}': {}",
secret_name, e
))
})?;

if !exists {
return Err(ToolError::ExecutionFailed(format!(
"secret '{}' not found. Store it first via 'ironclaw tool auth' or the web UI.",
secret_name
)));
}

grants.push(CredentialGrant {
secret_name: secret_name.clone(),
env_var: env_var.to_string(),
});

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

credentials allows callers to choose arbitrary environment variable names, but there is no validation of env_var. This can be abused to set sensitive process variables (e.g., LD_PRELOAD, BASH_ENV, RUST_LOG, etc.) inside the container. Consider validating env_var against a conservative pattern (e.g., ^[A-Z_][A-Z0-9_]*$), rejecting names containing =/NUL, and optionally denylisting particularly dangerous variables.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48b68d7. Added validate_env_var_name() that enforces ^[A-Z_][A-Z0-9_]*$ pattern and rejects a denylist of dangerous names (LD_PRELOAD, PATH, BASH_ENV, etc.).

Comment thread src/tools/builtin/job.rs Outdated
Comment on lines +963 to +969
if let Ok(job_ctx) = self.context_manager.get_context(job_id).await {
if job_ctx.user_id != ctx.user_id {
return Err(ToolError::ExecutionFailed(format!(
"job {} does not belong to current user",
&job_id_str[..8]
)));
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The job ownership check is bypassed if ContextManager::get_context(job_id) returns Err (e.g., after a process restart, or if contexts are not loaded for older jobs). In that case, the code proceeds to load and return events from the DB, potentially exposing other users' job logs. Consider making a missing context a hard failure (not found/unauthorized), or verifying ownership via a persisted job record in the Store before returning events.

Suggested change
if let Ok(job_ctx) = self.context_manager.get_context(job_id).await {
if job_ctx.user_id != ctx.user_id {
return Err(ToolError::ExecutionFailed(format!(
"job {} does not belong to current user",
&job_id_str[..8]
)));
}
// A missing context is treated as not found / unauthorized to avoid leaking logs.
let job_ctx = self
.context_manager
.get_context(job_id)
.await
.map_err(|_| {
ToolError::ExecutionFailed(format!(
"job {} not found or not accessible to current user",
&job_id_str[..8]
))
})?;
if job_ctx.user_id != ctx.user_id {
return Err(ToolError::ExecutionFailed(format!(
"job {} does not belong to current user",
&job_id_str[..8]
)));

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48b68d7. Changed if let Ok(job_ctx) to map_err that returns a hard error when the context is missing, closing the IDOR bypass.

Comment thread src/tools/builtin/job.rs Outdated
Comment on lines +1085 to +1092
// Verify the caller owns this job.
if let Ok(job_ctx) = self.context_manager.get_context(job_id).await {
if job_ctx.user_id != ctx.user_id {
return Err(ToolError::ExecutionFailed(format!(
"job {} does not belong to current user",
&job_id_str[..8]
)));
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ownership check is only enforced when ContextManager::get_context(job_id) succeeds; if it fails, the prompt is still queued. That can allow queuing prompts to jobs not owned by the caller (e.g., after restart or if the context isn't present). Consider treating a missing context as an error, or validating ownership via persisted job metadata before enqueuing the prompt.

Suggested change
// Verify the caller owns this job.
if let Ok(job_ctx) = self.context_manager.get_context(job_id).await {
if job_ctx.user_id != ctx.user_id {
return Err(ToolError::ExecutionFailed(format!(
"job {} does not belong to current user",
&job_id_str[..8]
)));
}
// Verify the caller owns this job. Treat missing context as an error.
let job_ctx = self
.context_manager
.get_context(job_id)
.await
.map_err(|_| {
ToolError::ExecutionFailed(format!(
"job context not found for job {}",
&job_id_str[..8]
))
})?;
if job_ctx.user_id != ctx.user_id {
return Err(ToolError::ExecutionFailed(format!(
"job {} does not belong to current user",
&job_id_str[..8]
)));

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48b68d7. Same fix as JobEventsTool: missing context now returns a hard error instead of silently skipping the ownership check.

Comment thread src/orchestrator/api.rs Outdated
Comment on lines +408 to +413
tracing::info!(
job_id = %job_id,
env_var = %grant.env_var,
secret = %grant.secret_name,
"Serving credential to container"
);

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The credentials endpoint logs the granted secret_name for every credential served. Even without logging values, secret names can leak sensitive information (e.g., which providers/accounts a user has configured). Consider lowering this to debug level, redacting/omitting secret, or logging only counts unless you explicitly need per-secret audit logs.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48b68d7. Changed from tracing::info! to tracing::debug! and removed the secret name from the log message.

Comment thread src/tools/builtin/job.rs
Comment on lines +605 to +608
let explicit_dir = params
.get("project_dir")
.and_then(|v| v.as_str())
.map(PathBuf::from);

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

project_dir is now user/LLM-provided, but resolve_project_dir will create_dir_all(&dir) before canonicalizing/prefix-checking. That means an attacker can trigger directory creation outside ~/.ironclaw/projects/ (including via symlink traversal) even though the function later errors. Since the schema says this should be an existing directory, consider requiring the explicit path to already exist and canonicalizing it without creating anything (and only auto-create when project_dir is omitted).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48b68d7. Explicit paths now require the directory to already exist (canonicalize before any creation). Only auto-generated paths (no explicit dir provided) get create_dir_all.

ilblackdragon and others added 3 commits February 13, 2026 11:41
… 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 <noreply@anthropic.com>
…ing)

- 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 <noreply@anthropic.com>
…andling, 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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 13, 2026 22:45
Resolve 6 conflicts:
- Cargo.toml: normalize subtle version to "2"
- Dockerfile.worker: adopt MSRV 1.92 from main
- auth.rs: keep serde import needed by CredentialGrant
- job_manager.rs: combine cached docker + cleanup-on-failure pattern
- proxy/http.rs: keep simpler CONNECT deny check
- worker/runtime.rs: use shared floor_char_boundary utility

Fix 2 clippy collapsible_if warnings from merged code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 26 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (3)

Dockerfile.worker:12

  • Rust 1.88 does not exist yet. As of February 2026, the latest stable Rust version would be around 1.84-1.86 (following the 6-week release cycle from version 1.85 in January 2025). Using a non-existent version will cause the Dockerfile build to fail when rustup tries to install it.

Check the actual Rust version required by rig-core and update both Dockerfile.worker line 12 and line 49, and Cargo.toml line 5 to use a valid Rust version that's actually released.

FROM rust:1.92-bookworm AS builder

Dockerfile.worker:49

  • Same issue: Rust 1.88.0 does not exist. The rustup install will fail. Update to a valid Rust version that exists.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \

src/worker/claude_bridge.rs:139

  • Same issue as in worker/runtime.rs: The SAFETY comment claims "bridge is single-threaded at this point (no child processes yet)", but the tokio runtime creates background threads. While the justification is that no child processes have been spawned yet, the tokio runtime threads can still access the environment concurrently, making std::env::set_var unsafe according to Rust's documentation.

Consider the same solution: passing credentials through a thread-safe structure rather than modifying the global process environment.

    ///
    /// 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");

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/sandbox/proxy/http.rs Outdated
Comment on lines +281 to +308
// Spawn a task to establish the tunnel after the upgrade completes
let target = target_addr.clone();
tokio::spawn(async move {
match hyper::upgrade::on(req).await {
Ok(upgraded) => {
let mut client_stream = TokioIo::new(upgraded);
match TcpStream::connect(&target).await {
Ok(mut server_stream) => {
if let Err(e) =
tokio::io::copy_bidirectional(&mut client_stream, &mut server_stream)
.await
{
tracing::debug!("Proxy: tunnel to {} closed: {}", target, e);
}
}
Err(e) => {
tracing::error!("Proxy: failed to connect to {}: {}", target, e);
}
}
}
Err(e) => {
tracing::error!("Proxy: upgrade failed for {}: {}", target, e);
}
}
});

// For CONNECT, we return 200 OK and the client will upgrade to TLS
// The actual TLS connection goes directly to the target, we just act as a tunnel
Response::builder()
.status(StatusCode::OK)
.body(empty_body())
.unwrap()
// Return 200 OK so the client begins the TLS handshake over the upgraded connection
make_response(StatusCode::OK, empty_body())

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CONNECT handler consumes req inside the spawned async task (line 284), but returns a 200 OK response outside the task (line 308). This creates a race condition: the response is sent before the upgrade is established. According to HTTP CONNECT semantics, the 200 OK response should only be sent after the connection to the target server succeeds. If TcpStream::connect(&target) fails, the client will receive 200 OK but the tunnel won't work.

Consider establishing the target connection before responding, or at minimum check that the upgrade succeeds before returning 200 OK.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually correct hyper CONNECT semantics. The 200 OK must be sent first to trigger the HTTP upgrade; hyper::upgrade::on(req) then resolves in the spawned task after the client receives the response. The client can't start TLS until it gets the 200, so there's no race. This is the standard pattern from hyper's own examples. No change needed.

Comment thread src/sandbox/manager.rs
Comment on lines +288 to +296
if stdout.len() > half_max {
stdout.truncate(half_max);
truncated = true;
}
if stderr.len() > half_max {
stderr.truncate(half_max);
truncated = true;
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truncate() method on String operates on bytes, not character boundaries. If stdout or stderr contains multi-byte UTF-8 characters, truncating at half_max bytes could split a character, causing invalid UTF-8. This is similar to the issue that was fixed in worker/runtime.rs truncate() function.

Consider using the same char-boundary-aware truncation approach from worker/runtime.rs lines 504-517, or extract that logic into a shared utility function.

Suggested change
if stdout.len() > half_max {
stdout.truncate(half_max);
truncated = true;
}
if stderr.len() > half_max {
stderr.truncate(half_max);
truncated = true;
}
// Truncate on UTF-8 character boundaries to avoid splitting multi-byte chars.
let mut truncate_to_char_boundary = |s: &mut String| {
if s.len() > half_max {
let mut idx = half_max;
// Move back to the nearest valid character boundary.
while idx > 0 && !s.is_char_boundary(idx) {
idx -= 1;
}
s.truncate(idx);
truncated = true;
}
};
truncate_to_char_boundary(&mut stdout);
truncate_to_char_boundary(&mut stderr);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a86a7c. stdout.truncate(half_max) and stderr.truncate(half_max) now use crate::util::floor_char_boundary() to find a safe UTF-8 character boundary before truncating, matching the pattern already used in worker/runtime.rs and tools/builtin/shell.rs.

serrrfirat
serrrfirat previously approved these changes Feb 14, 2026

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Verdict: APPROVE ✅

Well-structured PR adding three connected features for sandbox workflow improvements. Good test coverage (367 new test lines).

Key Findings

P1 — Credentials endpoint returns plaintext secrets over HTTP
src/orchestrator/api.rs:get_credentials_handler — Decrypted secret values are returned as JSON over the internal API. While this is internal-only (orchestrator ↔ container on localhost), a compromised container can exfiltrate all granted secrets in one call. Consider a one-time-use token pattern to reduce the exposure window.

P2 — containers field changed to pub(crate)
src/orchestrator/job_manager.rs — ContainerJobManager::containers is now pub(crate) to support tests directly accessing internal state. Tests should ideally use the public API to maintain encapsulation.

P2 — Job monitor has no backpressure beyond channel capacity
src/agent/job_monitor.rs — Uses an unbounded broadcast receiver. If the agent loop is slow, the monitor logs a warning on lag but drops events silently. For JobResult events this could mean missed completion notices. The mpsc inject channel (bounded at 64) mitigates this somewhat.

P3 — CONNECT tunnel has no timeout
src/sandbox/proxy/http.rs:handle_connect — The bidirectional copy runs until one side closes. A stuck connection could leak the spawned task. Consider adding a timeout.

P3 — Restarted jobs lose credential grants
src/channels/web/server.rs:jobs_restart_handler passes vec![] for credential_grants. Restarted jobs won't have access to their original secrets.

Highlights

  • CONNECT tunnel fix is critical — the old implementation returned 200 OK but never established the bidirectional TCP copy. Now properly uses hyper::upgrade → TcpStream::connect → copy_bidirectional.
  • Channel injection pattern is clean — inject_sender() with a Mutex<Option<Receiver>> that can only be taken once.
  • Credential grant lifecycle is correct — created with token, revoked with token.
  • Cached Docker connection avoids reconnecting per operation.

@tribendu

Copy link
Copy Markdown

PR #57 Review: feat: Add PR review tools, job monitor, and channel injection for E2E sandbox workflows

Summary

This PR implements a multi-faceted enhancement to the IronClaw AI agent framework, introducing three major subsystems: (1) job monitoring with channel injection for forwarding container output to the main agent loop, (2) credential injection system with secrets store integration for authenticated sandbox execution, and (3) enhanced PR review workflows. The changes span 26 files with ~1,600 lines added and ~200 removed, touching core systems (orchestrator, channel manager, sandbox proxy), tooling (new job_events and job_prompt tools), and security (path validation, credential grants, tunneling support).

Pros

Job Monitoring & Channel Injection:

  • Clean architecture using broadcast channel subscription → mpsc injection pattern
  • spawn_job_monitor provides well-documented event filtering logic with tests
  • Correct handles termination on both JobResult and channel closure
  • Avoids context pollution by skipping verbose tool_use/tool_result events

Credential Management:

  • Per-job credential grant model with automatic revocation on token cleanup
  • Strong env var name validation (uppercase, denies dangerous vars like LD_PRELOAD, PATH)
  • SecretsStore integration with usage tracking for audit trails
  • Clear separation between secret storage (encrypted) and runtime injection (plaintext env vars)

Sandbox Security:

  • validate_bind_mount_path enforces ~/.ironclaw/projects/ prefix with canonicalization
  • Docker connection caching reduces repeated socket operations
  • readonly_rootfs logic corrected: only disabled for FullAccess, not ReadOnly
  • Output truncation (64KB limit) prevents memory exhaustion
  • CONNECT tunneling implemented for HTTPS with proper bidirectional forwarding

Code Quality:

  • Comprehensive test coverage added (job_monitor, credential parsing, path validation, tool schemas)
  • Hex encoding optimization removes crate dependency
  • Error handling improvements (proper logging on failure, graceful degradation)
  • Well-structured module exports and type aliases

Concerns

Security - Unsafe std::env::set_var:

  • src/worker/claude_bridge.rs:182 and src/worker/runtime.rs:112 use unsafe { std::env::set_var() } but claim safety based on "single-threaded" reasoning. This is misleading: set_var modifies global process state and is inherently unsafe in concurrent programs. While workers may spawn tool subprocesses, set_var should generally be avoided. Consider either: (1) using std::env::set_var without unsafe (the function itself isn't actually unsafe), or (2) passing credentials via explicit process inheritance in Command::env().

Security - Path Validation TOCTOU:

  • validate_bind_mount_path in job_manager.rs computes canonicalization, but the binds.push() call happens later. A malicious actor could change symlinks between validation and mount. Since this is a single-tenant orchestrator with only create_job() callers having input control, the risk is reduced, but the pattern is worth documenting.

Security - Credential Exposure in Logs:

  • src/orchestrator/api.rs:415 logs "Serving credential to container" with env_var but not value. This is good, but consider adding a redaction helper for any future logging to prevent accidental credential exposure.

Race Condition - Monitor Lifecycle:

  • spawn_job_monitor documentation states it's "fire-and-forget" but there's no cleanup mechanism referenced. If the agent loop crashes and restarts, orphaned monitors could leak. Consider: (1) adding a cancellation token, or (2) documenting that broadcast sender drop cleans up subscribers.

Complexity Explosion:

  • CreateJobTool has grown to ~350 lines with multiple responsibilities (sandbox logic, credential parsing, project resolution). Consider extracting credential parsing and path validation to separate modules.
  • OrchestratorState has 7 fields with mixed concerns. Consider grouping related fields (auth, secrets, prompt queue) into nested structs.

Missing Documentation:

  • New tools (JobEventsTool, JobPromptTool) lack doc comments in the impl blocks, only have descriptions in Tool::description(). Add module-level docs explaining the event streaming UX.

Dockerfile Layering:

  • GitHub CLI installation in Dockerfile.worker combines curl download and apt install in a single RUN, which is good for cache efficiency, but the comment is unnecessarily verbose.

Suggestions

  1. Fix unsafe annotations: Remove unsafe around std::env::set_var (the function is not unsafe, global mutation is only logically unsafe due to shared state). Document why this is safe instead.

  2. Add cancellation support: Pass a tokio_util::sync::CancellationToken to spawn_job_monitor for graceful shutdown during orchestrator teardown.

  3. Extract credential parsing: Move parse_credentials and validate_env_var_name to a new src/tools/credential.rs module to reduce CreateJobTool complexity.

  4. Add integration test: Create an e2e test that spins up a minimal container and verifies: event broadcast → monitor injection → channel arrival. This would validate the full flow.

  5. Document CONNECT limitations: Add comment in handle_connect explaining credential injection won't work for HTTPS traffic and containers must use the /credentials endpoint instead.

  6. Consider atomic credential grants: If multiple secrets are requested and one fails, current validation exits early. For consistency, validate all secrets before any grant storage.

  7. Output truncation metric: Add a counter for how often output is truncated to monitor if the 64KB limit is too aggressive.

  8. Graceful degradation: If fetch_credentials fails midway, document whether containers should abort or continue without credentials. Current code logs and continues, which may be the intended behavior but isn't documented.


Reviewed using Kilocode CLI with cerebras/zai-glm-4.7 model (free tier).

…ulti-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 <noreply@anthropic.com>
…ool fixes)

Resolves conflicts in 4 files by keeping the `dyn Database` trait
abstraction from main while preserving the new sandbox workflow fields
(secrets_store, event_tx, inject_tx, prompt_queue) from workflows-2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 17, 2026 00:24
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 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

self.credential_mappings.iter().find(|m| {
m.host_patterns
.iter()
.any(|pattern| host_matches_pattern(&host_lower, pattern))

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find_credential lowercases the request host but compares it against host_patterns without normalizing the patterns. Since hostnames are case-insensitive, patterns containing uppercase characters will never match (both exact and wildcard), causing credentials not to be injected unexpectedly. Consider lowercasing patterns during comparison (or normalizing CredentialMapping.host_patterns on creation/loading).

Suggested change
.any(|pattern| host_matches_pattern(&host_lower, pattern))
.any(|pattern| {
let pattern_lower = pattern.to_lowercase();
host_matches_pattern(&host_lower, &pattern_lower)
})

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5aa243. host_matches_pattern now lowercases the pattern before comparison, so mixed-case patterns like "Api.Example.COM" match correctly against the already-lowercased host.

Comment thread src/tools/builtin/job.rs Outdated
Comment on lines +1031 to +1036
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;

let events =
self.store.list_job_events(job_id).await.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to load job events: {}", e))
})?;

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tool loads all job events from the DB (list_job_events) and only then slices to the last limit. For long-running jobs with large event logs this can become a memory/time hotspot. Consider adding a DB query that returns only the most recent N events (e.g., ORDER BY id DESC LIMIT $1) and using that here.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5aa243. The limit is now pushed into the SQL query via a new limit: Option<i64> parameter on Database::list_job_events. See the reply on the duplicate comment below for details.

Comment thread src/tools/builtin/job.rs Outdated
Comment on lines +509 to +548
let (canonical_dir, was_explicit) = match explicit {
Some(d) => {
// Explicit paths: validate BEFORE creating anything.
// The path must already exist (it comes from a previous job run).
let canonical = d.canonicalize().map_err(|e| {
ToolError::InvalidParameters(format!(
"explicit project dir {} does not exist or is inaccessible: {}",
d.display(),
e
))
})?;
if !canonical.starts_with(&canonical_base) {
return Err(ToolError::InvalidParameters(format!(
"project directory must be under {}",
canonical_base.display()
)));
}
(canonical, true)
}
None => {
let dir = canonical_base.join(project_id.to_string());
std::fs::create_dir_all(&dir).map_err(|e| {
ToolError::ExecutionFailed(format!(
"failed to create project dir {}: {}",
dir.display(),
e
))
})?;
let canonical = dir.canonicalize().map_err(|e| {
ToolError::ExecutionFailed(format!(
"failed to canonicalize project dir {}: {}",
dir.display(),
e
))
})?;
(canonical, false)
}
};

std::fs::create_dir_all(&dir).map_err(|e| {
ToolError::ExecutionFailed(format!(
"failed to create project dir {}: {}",
dir.display(),
e
))
})?;

// Canonicalize resolves symlinks, `..`, etc. so we can do a reliable prefix check.
let canonical_dir = dir.canonicalize().map_err(|e| {
ToolError::ExecutionFailed(format!(
"failed to canonicalize project dir {}: {}",
dir.display(),
e
))
})?;

if !canonical_dir.starts_with(&canonical_base) {
return Err(ToolError::InvalidParameters(format!(
"project directory must be under {}",
canonical_base.display()
)));
}
let _ = was_explicit;

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was_explicit is computed but intentionally unused (let _ = was_explicit;). This can be simplified by not binding it at all (or renaming to _was_explicit if you want to keep the boolean for readability), which makes the control flow clearer.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5aa243. Renamed to _was_explicit and removed the let _ = was_explicit; line.

Comment thread src/worker/claude_bridge.rs Outdated
Comment on lines +172 to +178
// Fetch and set credentials before spawning Claude.
// The spawned `claude` Command inherits env vars from this process.
let credentials = self.client.fetch_credentials().await?;
for cred in &credentials {
// SAFETY: bridge is single-threaded at this point (no child processes yet)
unsafe { std::env::set_var(&cred.env_var, &cred.value) };
}

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thread-safety concern as in WorkerRuntime: std::env::set_var mutates global process env and is not safe under the default multi-thread Tokio runtime. The current SAFETY comment (“single-threaded … no child processes yet”) doesn’t address other runtime threads. Consider setting env vars directly on the Command that spawns claude (and any other subprocesses) rather than mutating the process environment.

Copilot uses AI. Check for mistakes.
Comment thread src/sandbox/proxy/http.rs
Comment on lines +437 to +452
/// Build a response with guaranteed success (valid status + simple body cannot fail).
fn make_response(
status: StatusCode,
body: BoxBody<Bytes, Infallible>,
) -> Response<BoxBody<Bytes, Infallible>> {
Response::builder()
.status(status)
.header("Content-Type", "text/plain")
.body(full_body(Bytes::from(message)))
.unwrap()
.body(body)
.unwrap_or_else(|_| {
Response::new(
Full::new(Bytes::from("Internal error"))
.map_err(|_| unreachable!())
.boxed(),
)
})
}

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make_response’s fallback path uses Response::new(...), which defaults to HTTP 200. If Response::builder().body(...) ever fails (e.g., due to an invalid status/headers passed in the future), this would silently turn an error into a 200 OK. Consider reusing make_response_from_builder here or explicitly setting StatusCode::INTERNAL_SERVER_ERROR in the fallback response.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5aa243. The fallback in make_response now explicitly sets StatusCode::INTERNAL_SERVER_ERROR instead of defaulting to 200 OK.

ilblackdragon and others added 2 commits February 16, 2026 16:34
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 <noreply@anthropic.com>
…ssions

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 <noreply@anthropic.com>

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: feat: Add PR review tools, job monitor, and channel injection for E2E sandbox workflows

This is a large, feature-rich PR that adds significant capabilities: job monitoring, channel injection, credential grants, Claude Code bridge improvements, and HTTPS CONNECT tunneling. The architecture is well thought out and the test coverage is good. However, there are several issues that should be addressed before merging.

Critical Issues

1. credential_grants_json piggybacking on the description column is a correctness bug

The SandboxJobRecord.credential_grants_json field is stored in and read from the existing description column of agent_jobs:

// In history/store.rs (postgres backend):
credential_grants_json: r.get::<_, String>("description"),

// In save_sandbox_job (both backends):
// Previously: VALUES ($1, $2, '', $3, 'sandbox', ...)
// Now:        VALUES ($1, $2, $3, $4, 'sandbox', ...)
// Where $3 is credential_grants_json, mapped to the `description` column

This has multiple problems:

  • The description column semantically holds the task description. Non-sandbox jobs use it for its intended purpose. Overloading it with serialized JSON credentials is confusing and fragile.
  • When save_sandbox_job writes the record, what was previously hardcoded as '' for description is now credential_grants_json. Callers who read the description column for display purposes will get [{"secret_name":"github_token","env_var":"GITHUB_TOKEN"}] instead of a human-readable description.
  • The comment says "Stored in the description column of agent_jobs (unused for sandbox jobs)" -- but this assumption may break if any UI or API endpoint reads description generically.
  • There is no schema migration to add a proper column. This should either be a dedicated credential_grants_json column (with a migration) or stored in the existing metadata JSONB column.

2. Credential values served over HTTP without TLS

get_credentials_handler returns decrypted secret values as plaintext JSON over the orchestrator's internal HTTP API (port 50051). On Linux, this binds to 0.0.0.0:

let addr = if cfg!(target_os = "linux") {
    std::net::SocketAddr::from(([0, 0, 0, 0], port))
} else {
    std::net::SocketAddr::from(([127, 0, 0, 1], port))
};

The response body containing {"env_var": "GITHUB_TOKEN", "value": "ghp_actual_secret_here"} travels over the network unencrypted. While worker_auth_middleware protects against unauthorized access, any network observer can read the credential values. At minimum, add documentation noting this security consideration. Better: ensure the orchestrator API only communicates over the Docker bridge network.

Important Issues

3. let _ = was_explicit; is dead code

In resolve_project_dir, the was_explicit variable is computed in the destructuring but immediately suppressed:

let (canonical_dir, was_explicit) = match explicit { ... };
let _ = was_explicit;

If it is not needed, simplify to let (canonical_dir, _) = match explicit { ... };. If it will be used later, add a TODO.

4. Claude Code allowed tools list lost glob patterns

default_claude_code_allowed_tools() changed from glob patterns like "Bash(*)", "Edit(*)", "WebFetch(*)", "Task(*)" to plain names "Bash", "Edit", "WebFetch", "Task". Claude Code's .claude/settings.json permission system uses glob patterns for sub-tool matching. If Claude Code now accepts plain names, this should be documented. If not, this could cause permission denials inside containers -- tools would require user approval that never comes in a non-interactive container.

5. serde_json::to_string(&credential_grants).unwrap_or_default() silently swallows errors

let credential_grants_json = serde_json::to_string(&credential_grants).unwrap_or_default();

If serialization fails, the job gets persisted with credential_grants_json = "", and on restart the grants are silently lost via unwrap_or_default() on the deserialize side. This is a silent data loss path. Consider propagating the error or at least logging a warning.

6. copy_dir_recursive follows symlinks

The function uses src_path.is_dir() and std::fs::copy, both of which follow symlinks. A .claude directory on the host with symlinks pointing to sensitive files (e.g., ~/.ssh/id_rsa) could cause those files to be copied into the container's writable home directory. Consider using symlink_metadata() to detect and skip symlinks.

7. extra_env is cloned on every tool call in WorkerRuntime

let ctx = JobContext {
    extra_env: self.extra_env.clone(),
    ..Default::default()
};

This runs on every tool invocation. If there are many credentials and many tool calls, this creates unnecessary allocations. Consider wrapping extra_env in an Arc<HashMap<String, String>> to make cloning cheap.

Minor Issues

8. CredentialLocation::AuthorizationBasic and UrlPath silently skipped in proxy

The proxy's forward_request logs a warning but silently drops credentials for unsupported location types. The container has no indication that auth was not injected. Consider returning an error or documenting this as a known limitation.

9. SandboxManager Docker connection caching

Caching the Docker connection is good for reuse, but Docker connections can go stale (daemon restart, socket timeout). Consider a reconnect-on-error fallback.

10. report_status now takes State but discards the update.message type

The report_status handler was updated to accept State(state) and call update_worker_status, but StatusUpdate.message is typed as Option<String> while being used for both human-readable status text and iteration count. The semantics are clear from context but could benefit from stronger typing.

Positive Observations

  • The job monitor design (broadcast -> mpsc inject channel) is clean and well-tested with 4 focused tests
  • The credential grant system (validate at creation, serve decrypted on-demand, revoke on cleanup) is architecturally sound
  • The validate_env_var_name function with its denylist of dangerous env vars (LD_PRELOAD, PATH, HOME, etc.) is excellent defense-in-depth
  • The resolve_job_id short-prefix matching (git-style) is a nice UX improvement
  • Outstanding test coverage: job_monitor (4 tests), orchestrator API (7 new tests), credentials (4 tests), claude_bridge parsing (updated + new), proxy (2 new), job tools (12+ new tests)
  • The CONNECT tunnel implementation with copy_bidirectional and 30-minute timeout is well designed
  • Moving CredentialMapping/CredentialLocation to secrets/types.rs is a good refactoring
  • WAL mode + busy_timeout = 5000 for libSQL connections are important concurrency improvements
  • The make_response / make_response_from_builder helpers eliminate .unwrap() calls on Response construction in the proxy
  • The floor_char_boundary usage for output truncation prevents multi-byte panics

Summary

The main issue to fix before merging is the credential_grants_json being stored in the description column -- it needs a proper storage location (dedicated column with migration, or the metadata JSON field). The unencrypted credential transport should at least be documented as a security consideration. The rest are quality improvements for an already solid PR.

- 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 <noreply@anthropic.com>
@tribendu

Copy link
Copy Markdown

GitHub PR Review Batch 3 - nearai/ironclaw

PR #71: Fix undo/redo deadlock and checkpoint behavior

Summary

This PR addresses critical concurrency issues in the undo/redo system. The main changes are:

  1. Reordering lock acquisitions in undo_turn() and redo_turn() to lock session before undo manager, matching process_user_input() pattern to prevent deadlocks
  2. Changing undo() to return owned Checkpoint instead of &Checkpoint, fixing a bug where repeated undos returned the same checkpoint reference instead of walking backwards through history
  3. Adding current_turn and current_messages parameters to redo() so it can save the current state before popping from redo stack
  4. Adding comprehensive tests to verify undo/redo stack invariants and state preservation

Pros

  • Critical bug fix: The deadlock prevention is well-reasoned and follows existing patterns in the codebase
  • Ownership fix: Returning owned Checkpoint from undo() eliminates the borrow/snapshot bug that prevented repeated undos
  • Better API design: The new redo() signature is more explicit about state management
  • Excellent test coverage: Added 3 new tests covering repeated undo, undo/redo cycles, and stack size consistency
  • Documentation: Added helpful docstrings explaining the stack behavior and invariants

Concerns

  • Missing error handling: No check if thread.restore_from_messages() succeeds/fails before returning success
  • Clone overhead: The undo() function now clones messages when saving to redo stack, but this is unavoidable with the API change
  • Test gap: No test for the deadlock scenario itself (would require concurrent test setup)
  • Migration note: The change from &Checkpoint to Checkpoint is a breaking API change for any external users of UndoManager

Suggestions

  • Consider returning Result<Checkpoint, ...> from undo() and propagate restoration errors
  • Add integration test with concurrent calls to verify deadlock is truly fixed
  • Document the breaking API change in upgrade notes if this is a public library

PR #66: Update README architecture diagram to use Unicode box drawing

Summary

This PR updates the README.md architecture diagram from ASCII art to Unicode box drawing characters. The change improves readability and visual appeal of the system architecture diagram, using characters like ╭───╮, │, ● instead of +, -, |. The diagram structure and content remain unchanged.

Pros

  • Better aesthetics: Unicode box drawing creates cleaner, more professional diagrams
  • Improved readability: The visual distinction between components is clearer
  • Modern standard: Unicode box drawing is the standard for terminal-based diagrams in modern tools
  • Preserves structure: The logical diagram structure is unchanged

Concerns

  • Terminal compatibility: Some older terminals or Windows default fonts may not render Unicode box drawing correctly, showing replacement characters
  • Copy-paste issues: Unicode characters may cause issues in some markdown viewers or plain text contexts
  • Diff noise: This change makes future diff review harder since the diagram structure is visually the same but characters changed

Suggestions

  • Consider adding a note about terminal compatibility if users report issues
  • For CI environments, ensure the font/encoding is UTF-8 aware

PR #63: Memory Guardian and Cognitive Routines

Summary

This is a large PR (894 lines of new code) adding a comprehensive memory management system with two layers:

Cognitive Routines (Prompt-Level):

  • Pre-game routine (5-step checklist)
  • Checkpoint reminder templates
  • After-action review templates

Memory Guardian (System-Level):

  • Pre-compaction breadcrumbs (writes state snapshot before context loss)
  • Auto-breadcrumbs (every N tool calls, default 10)
  • Checkpoint gate (escalating pressure: gentle → firm → urgent at 12/20/30 calls)
  • Memory search tracking (reminds agent to search after 8 turns)

The PR also adds line number tracking to memory chunks for citation support (V9 migration), checkpoint tracker to Thread struct, and extensive testing (15 new tests).

Pros

  • Comprehensive documentation: Excellent module-level docs explaining the two-layer design rationale
  • Design rationale: Clear explanation of why both layers exist (instructions vs. enforcement)
  • Good defaults: Sensible thresholds (breadcrumb_interval=10, gate_thresholds=[12,20,30])
  • Defense in depth: Escalating pressure vs. hard blocking is a thoughtful UX choice
  • Sanitization functions: sanitize_checkpoint_text() prevents markdown/prompt injection
  • Extensive testing: 15 tests covering the full functionality

Concerns

  • Large PR: 894 lines makes review difficult; could be split into smaller PRs
  • Config not wired up: CognitiveConfig is defined but not loaded from configuration files or used to enable/disable features
  • Session lock pattern: Multiple session lock acquisitions in the agent loop could become a performance bottleneck with high tool call volume
  • Breadcrumb performance: Async I/O in the tool execution path for every Nth tool call adds latency
  • Missing integration tests: No tests verifying the guardian works end-to-end with compaction/reset
  • Tracker not persisted: CheckpointTracker is in-memory only, resets on session restart

Suggestions

  • Split into smaller PRs: (1) cognitive module structure, (2) guardian integration, (3) database migration
  • Wire up CognitiveConfig from config files/CLI flags so features can be toggled
  • Consider making breadcrumb writing non-blocking (spawn without await)
  • Document the performance impact of breadcrumb I/O
  • Add integration test verifying pre-compaction breadcrumb actually writes
  • Add metrics/logging for guardian actions (breadcrumb writes, gate triggers)

PR #62: Add Tinfoil private inference provider

Summary

This PR adds support for Tinfoil as a new LLM backend. The changes include:

  • Add Tinfoil variant to LlmBackend enum with string parsing
  • Add TinfoilConfig struct with api_key and model fields
  • Add create_tinfoil_provider() function that uses Rig's OpenAI client with https://inference.tinfoil.sh/v1 as base URL
  • Explicitly use completions_api() for Tinfoil (they don't support the newer Responses API)
  • Wire up in create_llm_provider() dispatch
  • Update setup wizard to include tinfoil field in config

Pros

  • Clean implementation: Follows existing provider patterns (Ollama, OpenAI-compatible)
  • Proper error handling: Returns LlmError::AuthFailed when tinfoil config is missing
  • Environment variable support: Reads TINFOIL_API_KEY and TINFOIL_MODEL from env with defaults
  • Good documentation: Note about why completions_api() is explicitly used

Concerns

  • Hardcoded base URL: TINFOIL_BASE_URL constant is not configurable via environment variable
  • No provider documentation: No usage examples or caveats in the main docs
  • Secret exposure: Uses expose_secret() on api_key when passing to Rig - this is the correct approach but worth verifying Rig doesn't log it
  • No health check: No verification that the Tinfoil endpoint works on startup

Suggestions

  • Make TINFOIL_BASE_URL configurable via environment variable
  • Add a brief section in the README about supported LLM providers including Tinfoil
  • Consider adding a quick health check or connection test on startup when tinfoil is configured

PR #61: Add PostgreSQL test workflow for CI

Summary

This PR adds a PostgreSQL with pgvector service to the GitHub Actions test workflow. The changes enable running tests against a real PostgreSQL database instead of libSQL-only testing. The service is configured with pgvector/pgvector:pg16 image and health checks.

Pros

  • Better test coverage: Enables testing against production database backend (PostgreSQL + pgvector)
  • Proper service configuration: Health checks, retries, and proper port mapping
  • Simple integration: Just adds the service and env var for DATABASE_URL

Concerns

  • No database fixtures: No test data setup or migrations for the test database
  • No test splitting: All tests still run against both backends; could be slow
  • Missing test matrix: Could benefit from matrix strategy testing libSQL and PostgreSQL in parallel
  • No cleanup between tests: Tests may interfere with each other's state
  • Flaky test risk: No fixture isolation or transaction rollback

Suggestions

  • Add a job matrix to test libSQL and PostgreSQL in parallel
  • Add migration step before running tests: cargo run -- database migrate
  • Consider wrapping tests in transactions with rollback for isolation
  • Add a separate integration test job vs. unit tests

PR #57: Sandbox job monitoring and credential grants persistence

Summary

This is a large PR (~6333 lines touched) introducing several features:

  1. Job Monitor System: New job_monitor.rs module spawns background tasks that watch for events from sandbox jobs (especially Claude Code) and inject assistant messages back into the agent loop via an injection channel in ChannelManager

  2. Credential Grants Persistence: Added credential_grants_json field to sandbox jobs for restoring credentials on job restart

  3. Claude Code OAuth Token Extraction: New logic to read OAuth tokens from macOS Keychain or Linux ~/.claude/.credentials.json for passing to containers via ANTHROPIC_API_KEY env var

  4. Database improvements: Changed LibSqlBackend::connect() to async and added PRAGMA busy_timeout = 5000 and PRAGMA journal_mode=WAL for better concurrency

  5. NEAR AI URL migration: Changed default from https://cloud-api.near.ai to https://private.near.ai

  6. Worker Docker image: Added GitHub CLI installation and improved layer caching

  7. Model command enhancement: /model now lists available models with the active one marked

Pros

  • Job monitoring is useful: Forwarding Claude Code output to the main agent enables better visibility into sub-agent work
  • Good test coverage: Job monitor has 4 unit tests for message forwarding, filtering, and lifecycle
  • Credential persistence: Restoring grants on job restart is a good UX improvement
  • Database concurrency: WAL mode and busy_timeout reduce lock contention in libSQL
  • OAuth extraction: Clever use of existing Claude auth rather than re-implementing auth

Concerns

  • Monolithic PR: Too many unrelated changes in one PR (job monitor, credentials, database, NEAR AI URLs, Docker, model command)
  • Cross-platform fragility: OAuth token extraction depends on macOS Keychain commands and Linux file paths - could break on different systems
  • Secret handling: OAuth token is passed via ANTHROPIC_API_KEY env var - this could be leaked in logs if the container or tool doesn't sanitize
  • Channel coupling: Job monitor couples Claude Code specifically to the agent loop - not generic for any job type
  • Database breaking change: Making connect() async is a breaking change for any code calling it synchronously
  • No rate limiting: Job monitor could spam the agent loop if the sub-agent produces many messages quickly
  • Missing test forOAuth: No tests for the token extraction logic

Suggestions

  • Split into smaller focused PRs: (1) job monitor, (2) credential grants, (3) database changes (4) Docker/URL migrations
  • Add rate limiting to job monitor (e.g., max 1 message/second)
  • Make job monitor generic (protocol-agnostic) instead of Claude-specific
  • Add tests for OAuth token extraction on both platforms (mocking the Keychain/file read)
  • Document the connect() async migration as a breaking change
  • Add warning logs when job monitor lags or drops messages
  • Verify ANTHROPIC_API_KEY doesn't leak in container logs

Summary

Highest Quality PRs

  1. PR fix: undo() peeks without popping, breaking repeated undo and leaking redo stack #71 - Well-scoped bug fix with excellent documentation and testing
  2. PR feat: add Tinfoil private inference provider #62 - Clean implementation following existing patterns

Needs Splitting

Minor Concerns

Recommendations

- 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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 17, 2026 17:22
@ilblackdragon

Copy link
Copy Markdown
Member Author

Addressing the remaining items from @ilblackdragon's self-review, fixed in 228a218:

Issue 4 — Claude Code allowed tools lost glob patterns:
Fixed. default_claude_code_allowed_tools() now returns glob patterns ("Bash(*)", "Edit(*)", "WebFetch(*)", "Task(*)", etc.) matching Claude Code's settings.json format, so sub-tool permissions resolve correctly inside non-interactive containers.

Issue 5 — serde_json::to_string(&credential_grants).unwrap_or_default() silently swallows errors:
Fixed. Both the serialization path in job.rs and the deserialization path in server.rs now emit tracing::warn! when serde_json fails, so silent credential grant data loss is no longer possible without a log trail.

Issue 7 — extra_env HashMap cloned per tool call:
Fixed. extra_env is now wrapped in Arc<HashMap<String, String>> in both WorkerRuntime and JobContext, making per-tool-call cloning a cheap reference count bump.

Issue 8 — AuthorizationBasic/UrlPath credential locations silently skipped in proxy:
Added a doc comment explaining this is a known limitation: the HTTP proxy only supports Header and QueryParam injection. Containers requiring AuthorizationBasic or UrlPath credentials should use the /credentials endpoint to fetch and apply them directly.

@ilblackdragon

Copy link
Copy Markdown
Member Author

Addressing items from @tribendu's review comment, fixed in 228a218:

Security — Path Validation TOCTOU:
Added a doc comment on validate_bind_mount_path explaining the TOCTOU window between canonicalization and the Docker binds.push() call. The comment documents the mitigation: this is a single-tenant orchestrator where only create_job() callers control inputs, so the race is not exploitable in practice.

Missing Documentation — JobEventsTool and JobPromptTool:
Added detailed doc comments on both tool structs. JobEventsTool now documents the event streaming UX (broadcast subscription, filtering, tail semantics). JobPromptTool documents the prompt queue mechanism (how prompts are enqueued for container workers and the ownership check flow).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/worker/claude_bridge.rs:706

  • The truncate function can return an empty string when max_len falls within the first multi-byte character. The loop reduces end to 0, resulting in an empty slice &s[..0]. This differs from the behavior of the similar code in src/util.rs::floor_char_boundary, which has more robust handling. Consider using crate::util::floor_char_boundary instead of reimplementing this logic, or adding a check to include at least one character when end reaches 0.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/sandbox/proxy/http.rs
Comment on lines +285 to +319
tokio::spawn(async move {
match hyper::upgrade::on(req).await {
Ok(upgraded) => {
let mut client_stream = TokioIo::new(upgraded);
match TcpStream::connect(&target).await {
Ok(mut server_stream) => {
let tunnel_timeout = std::time::Duration::from_secs(30 * 60);
match tokio::time::timeout(
tunnel_timeout,
tokio::io::copy_bidirectional(&mut client_stream, &mut server_stream),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::debug!("Proxy: tunnel to {} closed: {}", target, e);
}
Err(_) => {
tracing::info!(
"Proxy: tunnel to {} timed out after 30m, closing",
target
);
}
}
}
Err(e) => {
tracing::error!("Proxy: failed to connect to {}: {}", target, e);
}
}
}
Err(e) => {
tracing::error!("Proxy: upgrade failed for {}: {}", target, e);
}
}
});

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CONNECT tunnel implementation spawns a background task that is not tracked or awaited. If the proxy is shut down gracefully, these spawned tasks may continue running until the 30-minute timeout expires, potentially leaking resources. Consider storing tunnel tasks in a JoinSet or similar structure to enable graceful cancellation on shutdown, or document that the 30-minute timeout is acceptable and these tasks will be forcibly terminated on process exit.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressing 3 new Copilot comments in 99ed72d:

CONNECT tunnel task not tracked (#2818186815): Documented that the 30-minute timeout guarantees every tunnel task terminates. On process exit tasks are dropped by the runtime—no JoinSet needed.

Credentials endpoint logs secret names (#2818186890): Removed secret_name from error-level and warn-level logs in the credential grant path. The job_id is still logged for debugging.

DANGEROUS_ENV_VARS missing hijack vectors (#2818186950): Added PYTHONPATH, NODE_PATH, PERL5LIB, RUBYLIB, CLASSPATH, LD_AUDIT, JAVA_TOOL_OPTIONS, and MAVEN_OPTS to the denylist.

Comment thread src/orchestrator/api.rs
Comment on lines +394 to +404
let decrypted = secrets
.get_decrypted(&state.user_id, &grant.secret_name)
.await
.map_err(|e| {
tracing::error!(
job_id = %job_id,
secret = %grant.secret_name,
"Failed to decrypt secret for credential grant: {}", e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The credentials endpoint logs the secret name on every error at the error level. Even without logging values, secret names can leak sensitive information (e.g., which providers/accounts a user has configured, internal service names, etc.). The successful path already uses debug level logging. Consider also lowering the error log to warn or debug, or redacting the secret name from the error message.

Copilot uses AI. Check for mistakes.
Comment thread src/tools/builtin/job.rs
Comment on lines +481 to +495
const DANGEROUS_ENV_VARS: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"BASH_ENV",
"ENV",
"CDPATH",
"IFS",
"PATH",
"HOME",
"USER",
"SHELL",
"RUST_LOG",
];

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DANGEROUS_ENV_VARS denylist is missing several other dangerous variables that could affect container behavior: PYTHONPATH, NODE_PATH, PERL5LIB, RUBYLIB, GOPATH, CLASSPATH, LD_AUDIT, JAVA_TOOL_OPTIONS, MAVEN_OPTS. Consider expanding the denylist to include these additional hijack vectors, or document why the current set is considered sufficient.

Copilot uses AI. Check for mistakes.
ilblackdragon and others added 2 commits February 17, 2026 10:53
- 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 <noreply@anthropic.com>
# Conflicts:
#	src/agent/agent_loop.rs
Copilot AI review requested due to automatic review settings February 17, 2026 19:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tools/builtin/job.rs Outdated
Comment on lines +1087 to +1093
let limit = params.get("limit").and_then(|v| v.as_i64()).unwrap_or(50);

let events = self
.store
.list_job_events(job_id, Some(limit))
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to load job events: {}", e)))?;

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit parameter in list_job_events is user-controlled (from the LLM via JobEventsTool) but uses parameterized queries, which is secure. However, there's no upper bound validation on the limit value. An attacker could request limit: 999999999 to load millions of events into memory, causing a DoS. Consider adding a reasonable maximum (e.g., 1000 events) and rejecting larger values in the tool's execute method.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressing 4 new Copilot comments in 50d9b2f:

Job monitor startup observability (#2818812915): Promoted the startup log from debug to info level so monitor initialization is visible in production logs.

resolve_job_id prefix enumeration (#2818812925): Added a minimum 4-character prefix requirement. Combined with UUID randomness (4 hex chars = 65536 possibilities), this makes enumeration impractical.

credential_grants_json column size (#2818812940): Added a cap of 20 credential grants per job. Each grant is ~60 bytes of JSON, so max storage is well within TEXT column limits.

list_job_events unbounded limit (#2818812891): Clamped the limit parameter to 1..1000 to prevent memory abuse from large values.

Comment thread src/agent/job_monitor.rs
Comment on lines +34 to +105
pub fn spawn_job_monitor(
job_id: Uuid,
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
inject_tx: mpsc::Sender<IncomingMessage>,
) -> JoinHandle<()> {
let short_id = job_id.to_string()[..8].to_string();

tokio::spawn(async move {
tracing::debug!(job_id = %short_id, "Job monitor started");

loop {
match event_rx.recv().await {
Ok((ev_job_id, event)) => {
if ev_job_id != job_id {
continue;
}

match event {
SseEvent::JobMessage { role, content, .. } if role == "assistant" => {
let msg = IncomingMessage::new(
"job_monitor",
"system",
format!("[Job {}] Claude Code: {}", short_id, content),
);
if inject_tx.send(msg).await.is_err() {
tracing::debug!(
job_id = %short_id,
"Inject channel closed, stopping monitor"
);
break;
}
}
SseEvent::JobResult { status, .. } => {
let msg = IncomingMessage::new(
"job_monitor",
"system",
format!(
"[Job {}] Container finished (status: {})",
short_id, status
),
);
let _ = inject_tx.send(msg).await;
tracing::debug!(
job_id = %short_id,
status = %status,
"Job monitor exiting (job finished)"
);
break;
}
_ => {
// Skip tool_use, tool_result, status events
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
job_id = %short_id,
skipped = n,
"Job monitor lagged, some events were dropped"
);
}
Err(broadcast::error::RecvError::Closed) => {
tracing::debug!(
job_id = %short_id,
"Broadcast channel closed, stopping monitor"
);
break;
}
}
}
})
}

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spawn_job_monitor function does not track or return the JoinHandle anywhere after spawning the background task. While the documentation explains this is intentional (fire-and-forget with lifetime bound to channels), there's no mechanism to detect if the monitor task panics during initialization or setup. Consider adding a tracing event at task startup success to aid debugging, or add a oneshot channel confirmation that the task successfully started before returning from this function.

Copilot uses AI. Check for mistakes.
Comment thread src/tools/builtin/job.rs
Comment on lines +27 to +59
/// Resolve a job ID from a full UUID or a short prefix (like git short SHAs).
///
/// Tries full UUID parse first. If that fails, treats the input as a hex prefix
/// and searches the context manager for a unique match.
async fn resolve_job_id(input: &str, context_manager: &ContextManager) -> Result<Uuid, ToolError> {
// Fast path: full UUID
if let Ok(id) = Uuid::parse_str(input) {
return Ok(id);
}

// Prefix match against known jobs
let input_lower = input.to_lowercase();
let all_ids = context_manager.all_jobs().await;
let matches: Vec<Uuid> = all_ids
.into_iter()
.filter(|id| {
let hex = id.to_string().replace('-', "");
hex.starts_with(&input_lower)
})
.collect();

match matches.len() {
1 => Ok(matches[0]),
0 => Err(ToolError::InvalidParameters(format!(
"no job found matching prefix '{}'",
input
))),
n => Err(ToolError::InvalidParameters(format!(
"ambiguous prefix '{}' matches {} jobs, provide more characters",
input, n
))),
}
}

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefix matching uses replace('-', "") on the UUID hex representation, creating a 32-character hex string. However, if an attacker supplies a malicious prefix like "deadbeef" that happens to match multiple job IDs at the start of their hex representation, this could be used for timing attacks to enumerate valid job IDs. Consider adding rate limiting on resolve_job_id failures or requiring a minimum prefix length (e.g., 8 characters) to reduce the attack surface.

Copilot uses AI. Check for mistakes.
Comment thread src/tools/builtin/job.rs
Comment on lines +273 to +285
// Serialize credential grants so restarts can reload them.
let credential_grants_json = match serde_json::to_string(&credential_grants) {
Ok(json) => json,
Err(e) => {
tracing::warn!(
"Failed to serialize credential grants for job {}: {}. \
Grants will not survive a restart.",
job_id,
e
);
String::from("[]")
}
};

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The credential_grants_json is serialized and stored in the agent_jobs.description column (originally designed for job descriptions). If a user requests many credentials, the JSON could exceed typical TEXT column limits (e.g., 65KB in some databases). Consider adding validation to reject requests with excessive credential grants (e.g., max 10-20 grants), or ensure the database column can handle large credential lists without truncation.

Copilot uses AI. Check for mistakes.
ilblackdragon and others added 2 commits February 17, 2026 12:04
- 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 <noreply@anthropic.com>
Resolve conflicts with main's skills system and benchmarking additions:
- config.rs: kept both parse_oauth_access_token and SkillsConfig
- job.rs: kept with_monitor_deps/with_secrets, adopted pub visibility
- registry.rs: combined skill tool and job tool imports
- worker/api.rs: kept both test suites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 18, 2026 00:39
The merge resolution dropped the closing `}` for `impl SkillsConfig`,
causing a compilation error in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/worker/claude_bridge.rs:706

  • The truncate function here duplicates the logic of crate::util::floor_char_boundary. For consistency with the rest of the codebase (see src/worker/runtime.rs, src/sandbox/manager.rs, src/tools/builtin/shell.rs), consider using the shared utility instead:
fn truncate(s: &str, max_len: usize) -> &str {
    if s.len() <= max_len {
        s
    } else {
        let end = crate::util::floor_char_boundary(s, max_len);
        &s[..end]
    }
}

This makes the codebase more maintainable by avoiding duplicate implementations of the same UTF-8 boundary-finding logic.
src/config.rs:1677

  • The closing brace for impl SkillsConfig is missing. The resolve() function (lines 1660-1676) is inside the impl block, but line 1677 starts with a doc comment for a standalone function without closing the impl block first. This will cause a compilation error.

Add a closing brace } after line 1676 and before the doc comment at line 1678.

    }
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/sandbox/proxy/http.rs
Comment on lines +467 to +474
let mut resp = Response::new(
Full::new(Bytes::from("Internal error"))
.map_err(|_| unreachable!())
.boxed(),
);
*resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
resp
})

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copy_dir_recursive function uses map_err(|_| unreachable!()) when wrapping errors for the BoxBody type. However, the error type here is Infallible from hyper::body::Full, which truly cannot error. The unreachable!() will never execute, but using it as an error mapper is misleading. Consider using a more explicit pattern like .map_err(|e| match e {}) to make it clear this branch is impossible, or add a comment explaining why unreachable!() is safe here.

Copilot uses AI. Check for mistakes.
Comment on lines +549 to +550
if let Some(ref text) = block.text.as_deref().filter(|t| !t.is_empty())
{

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering expression block.text.as_deref().filter(|t| !t.is_empty()) converts Option<String> to Option<&str>, then filters out empty strings. However, the as_deref() call is unnecessary here since Option<String> already implements Deref<Target=str>. You can simplify this to:

if let Some(text) = block.text.as_ref().filter(|t| !t.is_empty()) {

This is clearer and avoids the extra indirection.

Copilot uses AI. Check for mistakes.
@ilblackdragon
ilblackdragon merged commit cfb579a into main Feb 18, 2026
2 checks passed
@ilblackdragon
ilblackdragon deleted the workflows-2 branch February 18, 2026 00:48
@github-actions github-actions Bot mentioned this pull request Feb 18, 2026
jaswinder6991 pushed a commit to jaswinder6991/ironclaw that referenced this pull request Feb 26, 2026
… sandbox workflows (nearai#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 <noreply@anthropic.com>

* fix: Address PR nearai#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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: Address PR nearai#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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: address PR nearai#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 <noreply@anthropic.com>

* fix: address second round of PR nearai#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 <noreply@anthropic.com>

* fix: address third round of PR nearai#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 <noreply@anthropic.com>

* fix: address fourth round of PR nearai#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 <noreply@anthropic.com>

* fix: address fifth round of PR nearai#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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
… sandbox workflows (nearai#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 <noreply@anthropic.com>

* fix: Address PR nearai#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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: Address PR nearai#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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: address PR nearai#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 <noreply@anthropic.com>

* fix: address second round of PR nearai#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 <noreply@anthropic.com>

* fix: address third round of PR nearai#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 <noreply@anthropic.com>

* fix: address fourth round of PR nearai#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 <noreply@anthropic.com>

* fix: address fifth round of PR nearai#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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants