mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
feat: add Claude adapter improvements for HITL support (#30)
* feat: add Claude adapter improvements for HITL support - Add question and permission handling for Claude sessions - Add Claude sender channel for interactive communication - Add stream event and control request handling - Update agent compatibility documentation * fix: restore Claude HITL streaming input and permission handling - Add streaming_input field to SpawnOptions for Claude stdin streaming - Enable --input-format stream-json, --permission-prompt-tool stdio flags - Pipe stdin for Claude (not just Codex) in spawn_streaming - Update Claude capabilities: permissions, questions, tool_calls, tool_results, streaming_deltas - Fix permission mode normalization to respect user's choice instead of forcing bypass - Add acceptEdits permission mode support - Add libc dependency for is_running_as_root check
This commit is contained in:
parent
c7d6482fd4
commit
0ee60920c8
7 changed files with 513 additions and 67 deletions
|
|
@ -8,14 +8,14 @@ The universal API normalizes different coding agents into a consistent interface
|
|||
|
||||
## Feature Matrix
|
||||
|
||||
| Feature | [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)* | [Codex](https://github.com/openai/codex) | [OpenCode](https://github.com/opencode-ai/opencode) | [Amp](https://ampcode.com) |
|
||||
| Feature | [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) | [Codex](https://github.com/openai/codex) | [OpenCode](https://github.com/opencode-ai/opencode) | [Amp](https://ampcode.com) |
|
||||
|---------|:-----------:|:-----:|:--------:|:---:|
|
||||
| Stability | Stable | Stable | Experimental | Experimental |
|
||||
| Text Messages | ✓ | ✓ | ✓ | ✓ |
|
||||
| Tool Calls | —* | ✓ | ✓ | ✓ |
|
||||
| Tool Results | —* | ✓ | ✓ | ✓ |
|
||||
| Questions (HITL) | —* | | ✓ | |
|
||||
| Permissions (HITL) | —* | | ✓ | |
|
||||
| Tool Calls | ✓ | ✓ | ✓ | ✓ |
|
||||
| Tool Results | ✓ | ✓ | ✓ | ✓ |
|
||||
| Questions (HITL) | ✓ | | ✓ | |
|
||||
| Permissions (HITL) | ✓ | | ✓ | |
|
||||
| Images | | ✓ | ✓ | |
|
||||
| File Attachments | | ✓ | ✓ | |
|
||||
| Session Lifecycle | | ✓ | ✓ | |
|
||||
|
|
@ -24,9 +24,7 @@ The universal API normalizes different coding agents into a consistent interface
|
|||
| Command Execution | | ✓ | | |
|
||||
| File Changes | | ✓ | | |
|
||||
| MCP Tools | | ✓ | | |
|
||||
| Streaming Deltas | | ✓ | ✓ | |
|
||||
|
||||
\* Coming imminently
|
||||
| Streaming Deltas | ✓ | ✓ | ✓ | |
|
||||
|
||||
## Feature Descriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@ Events / Message Flow
|
|||
| session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done |
|
||||
| message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message |
|
||||
| message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message |
|
||||
| message.delta | synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic |
|
||||
| tool call | synthetic from tool usage | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
|
||||
| tool result | synthetic from tool usage | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
|
||||
| permission.requested | none | none | type=permission.asked | none |
|
||||
| permission.resolved | none | none | type=permission.replied | none |
|
||||
| question.requested | ExitPlanMode tool (synthetic)| experimental request_user_input (payload) | type=question.asked | none |
|
||||
| question.resolved | ExitPlanMode reply (synthetic)| experimental request_user_input (payload) | type=question.replied / question.rejected | none |
|
||||
| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic |
|
||||
| tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call |
|
||||
| tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result |
|
||||
| permission.requested | control_request.can_use_tool | none | type=permission.asked | none |
|
||||
| permission.resolved | daemon reply to can_use_tool | none | type=permission.replied | none |
|
||||
| question.requested | tool_use (AskUserQuestion) | experimental request_user_input (payload) | type=question.asked | none |
|
||||
| question.resolved | tool_result (AskUserQuestion) | experimental request_user_input (payload) | type=question.replied / question.rejected | none |
|
||||
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error |
|
||||
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
|
||||
|
||||
|
|
@ -50,10 +50,11 @@ Synthetics
|
|||
| session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred |
|
||||
| item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible |
|
||||
| user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata |
|
||||
| question events (Claude) | Plan mode ExitPlanMode tool usage | question.requested/resolved | Synthetic mapping from tool call/result |
|
||||
| question events (Claude) | AskUserQuestion tool usage | question.requested/resolved | Derived from tool_use blocks (source=agent) |
|
||||
| native_session_id (Codex) | Codex uses threadId | native_session_id | Intentionally merged threadId into native_session_id |
|
||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||
| message.delta (Claude/Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
|
||||
| message.delta (Claude) | No native deltas emitted | item.delta | Synthetic delta with full message content; source=daemon |
|
||||
| message.delta (Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon |
|
||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||
| message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta |
|
||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||
|
|
@ -62,11 +63,12 @@ Delta handling
|
|||
|
||||
- Codex emits agent message and other deltas (e.g., item/agentMessage/delta).
|
||||
- OpenCode emits part deltas via message.part.updated with a delta string.
|
||||
- Claude and Amp do not emit deltas in their schemas.
|
||||
- Claude can emit stream_event deltas when partial streaming is enabled; Amp does not emit deltas.
|
||||
|
||||
Policy:
|
||||
- Always emit item.delta across all providers.
|
||||
- For providers without native deltas, emit a single synthetic delta containing the full content prior to item.completed.
|
||||
- For Claude when partial streaming is enabled, forward native deltas and skip the synthetic full-content delta.
|
||||
- For providers with native deltas, forward as-is; also emit item.completed when final content is known.
|
||||
|
||||
Message normalization notes
|
||||
|
|
|
|||
|
|
@ -160,6 +160,36 @@ Claude conflates agent mode and permission mode - `plan` is a permission restric
|
|||
| `plan` | `--permission-mode plan` | Read-only, must ExitPlanMode to execute |
|
||||
| `bypassPermissions` | `--dangerously-skip-permissions` | Skip all permission checks |
|
||||
|
||||
### Root Restrictions
|
||||
|
||||
**Claude refuses to run with `--dangerously-skip-permissions` when running as root (uid 0).**
|
||||
|
||||
This is a security measure built into Claude Code. When running as root:
|
||||
- The CLI outputs: `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`
|
||||
- The process exits immediately without executing
|
||||
|
||||
This affects container environments (Docker, Daytona, E2B, etc.) which commonly run as root.
|
||||
|
||||
**Workarounds:**
|
||||
1. Run as a non-root user in the container
|
||||
2. Use `default` permission mode (but this requires interactive approval)
|
||||
3. Use `acceptEdits` mode for file operations (still requires Bash approval)
|
||||
|
||||
### Headless Permission Behavior
|
||||
|
||||
When permissions are denied in headless mode (`--print --output-format stream-json`):
|
||||
|
||||
1. Claude emits a `tool_use` event for the tool (e.g., Write, Bash)
|
||||
2. A `user` event follows with `tool_result` containing `is_error: true`
|
||||
3. Error message: `"Claude requested permissions to X, but you haven't granted it yet."`
|
||||
4. Final `result` event includes `permission_denials` array listing all denied tools
|
||||
|
||||
```json
|
||||
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{...}}]}}
|
||||
{"type":"user","message":{"content":[{"type":"tool_result","is_error":true,"content":"Claude requested permissions to write to /tmp/test.txt, but you haven't granted it yet."}]}}
|
||||
{"type":"result","permission_denials":[{"tool_name":"Write","tool_use_id":"...","tool_input":{...}}]}
|
||||
```
|
||||
|
||||
### Subagent Types
|
||||
|
||||
Claude supports spawning subagents via the `Task` tool with `subagent_type`:
|
||||
|
|
|
|||
|
|
@ -217,7 +217,6 @@ impl AgentManager {
|
|||
match agent {
|
||||
AgentId::Claude => {
|
||||
command
|
||||
.arg("--print")
|
||||
.arg("--output-format")
|
||||
.arg("stream-json")
|
||||
.arg("--verbose");
|
||||
|
|
@ -234,9 +233,21 @@ impl AgentManager {
|
|||
Some("bypass") => {
|
||||
command.arg("--dangerously-skip-permissions");
|
||||
}
|
||||
Some("acceptEdits") => {
|
||||
command.arg("--permission-mode").arg("acceptEdits");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
command.arg(&options.prompt);
|
||||
if options.streaming_input {
|
||||
command
|
||||
.arg("--input-format")
|
||||
.arg("stream-json")
|
||||
.arg("--permission-prompt-tool")
|
||||
.arg("stdio")
|
||||
.arg("--include-partial-messages");
|
||||
} else {
|
||||
command.arg("--print").arg("--").arg(&options.prompt);
|
||||
}
|
||||
}
|
||||
AgentId::Codex => {
|
||||
if options.session_id.is_some() {
|
||||
|
|
@ -305,15 +316,18 @@ impl AgentManager {
|
|||
pub fn spawn_streaming(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
options: SpawnOptions,
|
||||
mut options: SpawnOptions,
|
||||
) -> Result<StreamingSpawn, AgentError> {
|
||||
let codex_options = if agent == AgentId::Codex {
|
||||
Some(options.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if agent == AgentId::Claude {
|
||||
options.streaming_input = true;
|
||||
}
|
||||
let mut command = self.build_command(agent, &options)?;
|
||||
if agent == AgentId::Codex {
|
||||
if matches!(agent, AgentId::Codex | AgentId::Claude) {
|
||||
command.stdin(Stdio::piped());
|
||||
}
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
|
@ -539,7 +553,6 @@ impl AgentManager {
|
|||
match agent {
|
||||
AgentId::Claude => {
|
||||
command
|
||||
.arg("--print")
|
||||
.arg("--output-format")
|
||||
.arg("stream-json")
|
||||
.arg("--verbose");
|
||||
|
|
@ -556,9 +569,21 @@ impl AgentManager {
|
|||
Some("bypass") => {
|
||||
command.arg("--dangerously-skip-permissions");
|
||||
}
|
||||
Some("acceptEdits") => {
|
||||
command.arg("--permission-mode").arg("acceptEdits");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
command.arg(&options.prompt);
|
||||
if options.streaming_input {
|
||||
command
|
||||
.arg("--input-format")
|
||||
.arg("stream-json")
|
||||
.arg("--permission-prompt-tool")
|
||||
.arg("stdio")
|
||||
.arg("--include-partial-messages");
|
||||
} else {
|
||||
command.arg(&options.prompt);
|
||||
}
|
||||
}
|
||||
AgentId::Codex => {
|
||||
if options.session_id.is_some() {
|
||||
|
|
@ -646,6 +671,8 @@ pub struct SpawnOptions {
|
|||
pub session_id: Option<String>,
|
||||
pub working_dir: Option<PathBuf>,
|
||||
pub env: HashMap<String, String>,
|
||||
/// Use stream-json input via stdin (Claude only).
|
||||
pub streaming_input: bool,
|
||||
}
|
||||
|
||||
impl SpawnOptions {
|
||||
|
|
@ -659,6 +686,7 @@ impl SpawnOptions {
|
|||
session_id: None,
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
streaming_input: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ tracing-subscriber.workspace = true
|
|||
include_dir.workspace = true
|
||||
tempfile = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util.workspace = true
|
||||
insta.workspace = true
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ struct SessionState {
|
|||
broadcaster: broadcast::Sender<UniversalEvent>,
|
||||
opencode_stream_started: bool,
|
||||
codex_sender: Option<mpsc::UnboundedSender<String>>,
|
||||
claude_sender: Option<mpsc::UnboundedSender<String>>,
|
||||
session_started_emitted: bool,
|
||||
last_claude_message_id: Option<String>,
|
||||
claude_message_counter: u64,
|
||||
|
|
@ -322,6 +323,7 @@ impl SessionState {
|
|||
broadcaster,
|
||||
opencode_stream_started: false,
|
||||
codex_sender: None,
|
||||
claude_sender: None,
|
||||
session_started_emitted: false,
|
||||
last_claude_message_id: None,
|
||||
claude_message_counter: 0,
|
||||
|
|
@ -382,6 +384,15 @@ impl SessionState {
|
|||
self.codex_sender.clone()
|
||||
}
|
||||
|
||||
fn set_claude_sender(&mut self, sender: Option<mpsc::UnboundedSender<String>>) {
|
||||
self.claude_sender = sender;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn claude_sender(&self) -> Option<mpsc::UnboundedSender<String>> {
|
||||
self.claude_sender.clone()
|
||||
}
|
||||
|
||||
fn normalize_conversion(&mut self, mut conversion: EventConversion) -> Vec<EventConversion> {
|
||||
if self.native_session_id.is_none() && conversion.native_session_id.is_some() {
|
||||
self.native_session_id = conversion.native_session_id.clone();
|
||||
|
|
@ -1621,6 +1632,11 @@ impl SessionManager {
|
|||
|
||||
let manager = self.agent_manager.clone();
|
||||
let prompt = message;
|
||||
let initial_input = if session_snapshot.agent == AgentId::Claude {
|
||||
Some(claude_user_message_line(&session_snapshot, &prompt))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let credentials = tokio::task::spawn_blocking(move || {
|
||||
let options = CredentialExtractionOptions::new();
|
||||
extract_all_credentials(&options)
|
||||
|
|
@ -1630,7 +1646,7 @@ impl SessionManager {
|
|||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
let spawn_options = build_spawn_options(&session_snapshot, prompt, credentials);
|
||||
let spawn_options = build_spawn_options(&session_snapshot, prompt.clone(), credentials);
|
||||
let agent_id = session_snapshot.agent;
|
||||
let spawn_result =
|
||||
tokio::task::spawn_blocking(move || manager.spawn_streaming(agent_id, spawn_options))
|
||||
|
|
@ -1649,7 +1665,7 @@ impl SessionManager {
|
|||
let manager = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
manager
|
||||
.consume_spawn(session_id, agent_id, spawn_result)
|
||||
.consume_spawn(session_id, agent_id, spawn_result, initial_input)
|
||||
.await;
|
||||
});
|
||||
|
||||
|
|
@ -1847,7 +1863,7 @@ impl SessionManager {
|
|||
question_id: &str,
|
||||
answers: Vec<Vec<String>>,
|
||||
) -> Result<(), SandboxError> {
|
||||
let (agent, native_session_id, pending_question) = {
|
||||
let (agent, native_session_id, pending_question, claude_sender) = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||
SandboxError::SessionNotFound {
|
||||
|
|
@ -1863,7 +1879,12 @@ impl SessionManager {
|
|||
if let Some(err) = session.ended_error() {
|
||||
return Err(err);
|
||||
}
|
||||
(session.agent, session.native_session_id.clone(), pending)
|
||||
(
|
||||
session.agent,
|
||||
session.native_session_id.clone(),
|
||||
pending,
|
||||
session.claude_sender(),
|
||||
)
|
||||
};
|
||||
|
||||
let response = answers.first().and_then(|inner| inner.first()).cloned();
|
||||
|
|
@ -1877,6 +1898,16 @@ impl SessionManager {
|
|||
})?;
|
||||
self.opencode_question_reply(&agent_session_id, question_id, answers)
|
||||
.await?;
|
||||
} else if agent == AgentId::Claude {
|
||||
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
|
||||
let response_text = response.clone().unwrap_or_default();
|
||||
let line = claude_tool_result_line(&session_id, question_id, &response_text, false);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
} else {
|
||||
// TODO: Forward question replies to subprocess agents.
|
||||
}
|
||||
|
|
@ -1905,7 +1936,7 @@ impl SessionManager {
|
|||
session_id: &str,
|
||||
question_id: &str,
|
||||
) -> Result<(), SandboxError> {
|
||||
let (agent, native_session_id, pending_question) = {
|
||||
let (agent, native_session_id, pending_question, claude_sender) = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||
SandboxError::SessionNotFound {
|
||||
|
|
@ -1921,7 +1952,12 @@ impl SessionManager {
|
|||
if let Some(err) = session.ended_error() {
|
||||
return Err(err);
|
||||
}
|
||||
(session.agent, session.native_session_id.clone(), pending)
|
||||
(
|
||||
session.agent,
|
||||
session.native_session_id.clone(),
|
||||
pending,
|
||||
session.claude_sender(),
|
||||
)
|
||||
};
|
||||
|
||||
if agent == AgentId::Opencode {
|
||||
|
|
@ -1933,6 +1969,20 @@ impl SessionManager {
|
|||
})?;
|
||||
self.opencode_question_reject(&agent_session_id, question_id)
|
||||
.await?;
|
||||
} else if agent == AgentId::Claude {
|
||||
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
|
||||
let line = claude_tool_result_line(
|
||||
&session_id,
|
||||
question_id,
|
||||
"User rejected the question.",
|
||||
true,
|
||||
);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
} else {
|
||||
// TODO: Forward question rejections to subprocess agents.
|
||||
}
|
||||
|
|
@ -1963,7 +2013,7 @@ impl SessionManager {
|
|||
reply: PermissionReply,
|
||||
) -> Result<(), SandboxError> {
|
||||
let reply_for_status = reply.clone();
|
||||
let (agent, native_session_id, pending_permission) = {
|
||||
let (agent, native_session_id, pending_permission, claude_sender) = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||
SandboxError::SessionNotFound {
|
||||
|
|
@ -1983,6 +2033,7 @@ impl SessionManager {
|
|||
session.agent,
|
||||
session.native_session_id.clone(),
|
||||
pending,
|
||||
session.claude_sender(),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -2050,6 +2101,44 @@ impl SessionManager {
|
|||
})?;
|
||||
self.opencode_permission_reply(&agent_session_id, permission_id, reply.clone())
|
||||
.await?;
|
||||
} else if agent == AgentId::Claude {
|
||||
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
let metadata = pending_permission
|
||||
.as_ref()
|
||||
.and_then(|pending| pending.metadata.as_ref())
|
||||
.and_then(Value::as_object);
|
||||
let updated_input = metadata
|
||||
.and_then(|map| map.get("input"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
let mut response_map = serde_json::Map::new();
|
||||
match reply {
|
||||
PermissionReply::Reject => {
|
||||
response_map.insert(
|
||||
"message".to_string(),
|
||||
Value::String("Permission denied.".to_string()),
|
||||
);
|
||||
}
|
||||
PermissionReply::Once | PermissionReply::Always => {
|
||||
if !updated_input.is_null() {
|
||||
response_map.insert("updatedInput".to_string(), updated_input);
|
||||
}
|
||||
}
|
||||
}
|
||||
let response_value = Value::Object(response_map);
|
||||
|
||||
let behavior = match reply {
|
||||
PermissionReply::Reject => "deny",
|
||||
PermissionReply::Once | PermissionReply::Always => "allow",
|
||||
};
|
||||
|
||||
let line = claude_control_response_line(permission_id, behavior, response_value);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
} else {
|
||||
// TODO: Forward permission replies to subprocess agents.
|
||||
}
|
||||
|
|
@ -2151,6 +2240,7 @@ impl SessionManager {
|
|||
session_id: String,
|
||||
agent: AgentId,
|
||||
spawn: StreamingSpawn,
|
||||
initial_input: Option<String>,
|
||||
) {
|
||||
let StreamingSpawn {
|
||||
mut child,
|
||||
|
|
@ -2197,6 +2287,22 @@ impl SessionManager {
|
|||
if let (Some(state), Some(sender)) = (codex_state.as_mut(), codex_sender.as_ref()) {
|
||||
state.start(sender);
|
||||
}
|
||||
} else if agent == AgentId::Claude {
|
||||
if let Some(stdin) = stdin {
|
||||
let (writer_tx, writer_rx) = mpsc::unbounded_channel::<String>();
|
||||
{
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||
session.set_claude_sender(Some(writer_tx.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(initial) = initial_input {
|
||||
let _ = writer_tx.send(initial);
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
write_lines(stdin, writer_rx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(line) = rx.recv().await {
|
||||
|
|
@ -2214,6 +2320,14 @@ impl SessionManager {
|
|||
}
|
||||
}
|
||||
} else if agent == AgentId::Claude {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(&line) {
|
||||
if value.get("type").and_then(Value::as_str) == Some("result") {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||
session.set_claude_sender(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let conversions = self.parse_claude_line(&line, &session_id).await;
|
||||
if !conversions.is_empty() {
|
||||
let _ = self.record_conversions(&session_id, conversions).await;
|
||||
|
|
@ -2231,6 +2345,11 @@ impl SessionManager {
|
|||
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||
session.set_codex_sender(None);
|
||||
}
|
||||
} else if agent == AgentId::Claude {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = Self::session_mut(&mut sessions, &session_id) {
|
||||
session.set_claude_sender(None);
|
||||
}
|
||||
}
|
||||
|
||||
if terminate_early {
|
||||
|
|
@ -3779,14 +3898,14 @@ fn agent_supports_item_started(agent: AgentId) -> bool {
|
|||
|
||||
fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||
match agent {
|
||||
// Headless Claude CLI does not expose AskUserQuestion and does not emit tool_result,
|
||||
// so we keep these capabilities off until we switch to an SDK-backed wrapper.
|
||||
// Claude CLI supports tool calls/results and permission prompts via the SDK control protocol,
|
||||
// but we still emit synthetic item.started events.
|
||||
AgentId::Claude => AgentCapabilities {
|
||||
plan_mode: false,
|
||||
permissions: false,
|
||||
questions: false,
|
||||
tool_calls: false,
|
||||
tool_results: false,
|
||||
permissions: true,
|
||||
questions: true,
|
||||
tool_calls: true,
|
||||
tool_results: true,
|
||||
text_messages: true,
|
||||
images: false,
|
||||
file_attachments: false,
|
||||
|
|
@ -3797,7 +3916,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
command_execution: false,
|
||||
file_changes: false,
|
||||
mcp_tools: false,
|
||||
streaming_deltas: false,
|
||||
streaming_deltas: true,
|
||||
item_started: false,
|
||||
shared_process: false, // per-turn subprocess with --resume
|
||||
},
|
||||
|
|
@ -3990,12 +4109,24 @@ fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result<Stri
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if the current process is running as root (uid 0)
|
||||
fn is_running_as_root() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::getuid() == 0 }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_permission_mode(
|
||||
agent: AgentId,
|
||||
permission_mode: Option<&str>,
|
||||
) -> Result<String, SandboxError> {
|
||||
let mode = match permission_mode.unwrap_or("default") {
|
||||
"default" | "plan" | "bypass" => permission_mode.unwrap_or("default"),
|
||||
"default" | "plan" | "bypass" | "acceptEdits" => permission_mode.unwrap_or("default"),
|
||||
value => {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: format!("invalid permission mode: {value}"),
|
||||
|
|
@ -4004,14 +4135,20 @@ fn normalize_permission_mode(
|
|||
}
|
||||
};
|
||||
if agent == AgentId::Claude {
|
||||
if mode == "plan" {
|
||||
return Err(SandboxError::ModeNotSupported {
|
||||
agent: agent.as_str().to_string(),
|
||||
mode: mode.to_string(),
|
||||
// Claude refuses --dangerously-skip-permissions when running as root,
|
||||
// which is common in container environments (Docker, Daytona, E2B).
|
||||
// Return an error if user explicitly requests bypass while running as root.
|
||||
if mode == "bypass" && is_running_as_root() {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "permission mode 'bypass' is not supported when running as root (Claude refuses --dangerously-skip-permissions with root privileges)".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
return Ok("bypass".to_string());
|
||||
// Pass through bypass/acceptEdits/plan if explicitly requested, otherwise use default
|
||||
if mode == "bypass" || mode == "acceptEdits" || mode == "plan" {
|
||||
return Ok(mode.to_string());
|
||||
}
|
||||
return Ok("default".to_string());
|
||||
}
|
||||
let supported = match agent {
|
||||
AgentId::Claude => false,
|
||||
|
|
@ -4117,6 +4254,87 @@ fn build_spawn_options(
|
|||
options
|
||||
}
|
||||
|
||||
fn claude_input_session_id(session: &SessionSnapshot) -> String {
|
||||
session
|
||||
.native_session_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| session.session_id.clone())
|
||||
}
|
||||
|
||||
fn claude_user_message_line(session: &SessionSnapshot, message: &str) -> String {
|
||||
let session_id = claude_input_session_id(session);
|
||||
serde_json::json!({
|
||||
"type": "user",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": message,
|
||||
},
|
||||
"parent_tool_use_id": null,
|
||||
"session_id": session_id,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn claude_tool_result_line(
|
||||
session_id: &str,
|
||||
tool_use_id: &str,
|
||||
content: &str,
|
||||
is_error: bool,
|
||||
) -> String {
|
||||
serde_json::json!({
|
||||
"type": "user",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": content,
|
||||
"is_error": is_error,
|
||||
}],
|
||||
},
|
||||
"parent_tool_use_id": null,
|
||||
"session_id": session_id,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn claude_control_response_line(
|
||||
request_id: &str,
|
||||
behavior: &str,
|
||||
response: Value,
|
||||
) -> String {
|
||||
let mut response_obj = serde_json::Map::new();
|
||||
response_obj.insert(
|
||||
"behavior".to_string(),
|
||||
Value::String(behavior.to_string()),
|
||||
);
|
||||
if let Some(message) = response.get("message") {
|
||||
response_obj.insert("message".to_string(), message.clone());
|
||||
}
|
||||
if let Some(updated_input) = response.get("updatedInput") {
|
||||
response_obj.insert("updatedInput".to_string(), updated_input.clone());
|
||||
}
|
||||
if let Some(updated_permissions) = response.get("updatedPermissions") {
|
||||
response_obj.insert(
|
||||
"updatedPermissions".to_string(),
|
||||
updated_permissions.clone(),
|
||||
);
|
||||
}
|
||||
if let Some(interrupt) = response.get("interrupt") {
|
||||
response_obj.insert("interrupt".to_string(), interrupt.clone());
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"type": "control_response",
|
||||
"response": {
|
||||
"subtype": "success",
|
||||
"request_id": request_id,
|
||||
"response": Value::Object(response_obj),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ use crate::{
|
|||
ContentPart,
|
||||
EventConversion,
|
||||
ItemEventData,
|
||||
ItemDeltaData,
|
||||
ItemKind,
|
||||
ItemRole,
|
||||
ItemStatus,
|
||||
PermissionEventData,
|
||||
PermissionStatus,
|
||||
QuestionEventData,
|
||||
QuestionStatus,
|
||||
SessionStartedData,
|
||||
|
|
@ -31,11 +34,14 @@ pub fn event_to_universal_with_session(
|
|||
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let mut conversions = match event_type {
|
||||
"system" => vec![system_event_to_universal(event)],
|
||||
"user" => Vec::new(),
|
||||
"user" => user_event_to_universal(event),
|
||||
"assistant" => assistant_event_to_universal(event, &session_id),
|
||||
"tool_use" => tool_use_event_to_universal(event, &session_id),
|
||||
"tool_result" => tool_result_event_to_universal(event),
|
||||
"result" => result_event_to_universal(event, &session_id),
|
||||
"stream_event" => stream_event_to_universal(event),
|
||||
"control_request" => control_request_to_universal(event)?,
|
||||
"control_response" | "keep_alive" | "update_environment_variables" => Vec::new(),
|
||||
_ => return Err(format!("unsupported Claude event type: {event_type}")),
|
||||
};
|
||||
|
||||
|
|
@ -85,6 +91,44 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
|
|||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||
let is_exit_plan_mode = matches!(
|
||||
name,
|
||||
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
|
||||
);
|
||||
let is_question_tool = matches!(
|
||||
name,
|
||||
"AskUserQuestion" | "ask_user_question" | "askUserQuestion"
|
||||
| "ask-user-question"
|
||||
) || is_exit_plan_mode;
|
||||
let has_question_payload = input.get("questions").is_some();
|
||||
if is_question_tool || has_question_payload {
|
||||
if let Some(question) = question_from_claude_input(&input, call_id.clone()) {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::QuestionRequested,
|
||||
UniversalEventData::Question(question),
|
||||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
} else if is_exit_plan_mode {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::QuestionRequested,
|
||||
UniversalEventData::Question(QuestionEventData {
|
||||
question_id: call_id.clone(),
|
||||
prompt: "Approve plan execution?".to_string(),
|
||||
options: vec![
|
||||
"approve".to_string(),
|
||||
"reject".to_string(),
|
||||
],
|
||||
response: None,
|
||||
status: QuestionStatus::Requested,
|
||||
}),
|
||||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
|
||||
let tool_item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
|
|
@ -120,7 +164,49 @@ fn assistant_event_to_universal(event: &Value, session_id: &str) -> Vec<EventCon
|
|||
status: ItemStatus::InProgress,
|
||||
};
|
||||
|
||||
conversions.extend(message_started_events(message_item, message_parts));
|
||||
conversions.extend(message_started_events(message_item));
|
||||
conversions
|
||||
}
|
||||
|
||||
fn user_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||
let mut conversions = Vec::new();
|
||||
let content = event
|
||||
.get("message")
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
for block in content {
|
||||
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
if block_type != "tool_result" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.or_else(|| block.get("toolUseId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||
let output = block.get("content").cloned().unwrap_or(Value::Null);
|
||||
let output_text = serde_json::to_string(&output).unwrap_or_default();
|
||||
|
||||
let tool_item = UniversalItem {
|
||||
item_id: next_temp_id("tmp_claude_tool_result"),
|
||||
native_item_id: Some(tool_use_id.clone()),
|
||||
parent_id: None,
|
||||
kind: ItemKind::ToolResult,
|
||||
role: Some(ItemRole::Tool),
|
||||
content: vec![ContentPart::ToolResult {
|
||||
call_id: tool_use_id,
|
||||
output: output_text,
|
||||
}],
|
||||
status: ItemStatus::Completed,
|
||||
};
|
||||
conversions.extend(item_events(tool_item, true));
|
||||
}
|
||||
|
||||
conversions
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +227,14 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
|
|||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
|
||||
|
||||
let is_exit_plan_mode = matches!(
|
||||
name,
|
||||
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
|
||||
);
|
||||
let is_question_tool = matches!(
|
||||
name,
|
||||
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
|
||||
);
|
||||
) || is_exit_plan_mode;
|
||||
let has_question_payload = input.get("questions").is_some();
|
||||
if is_question_tool || has_question_payload {
|
||||
if let Some(question) = question_from_claude_input(&input, id.clone()) {
|
||||
|
|
@ -155,6 +245,20 @@ fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConv
|
|||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
} else if is_exit_plan_mode {
|
||||
conversions.push(
|
||||
EventConversion::new(
|
||||
UniversalEventType::QuestionRequested,
|
||||
UniversalEventData::Question(QuestionEventData {
|
||||
question_id: id.clone(),
|
||||
prompt: "Approve plan execution?".to_string(),
|
||||
options: vec!["approve".to_string(), "reject".to_string()],
|
||||
response: None,
|
||||
status: QuestionStatus::Requested,
|
||||
}),
|
||||
)
|
||||
.with_raw(Some(event.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +330,88 @@ fn tool_result_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
|||
conversions
|
||||
}
|
||||
|
||||
fn stream_event_to_universal(event: &Value) -> Vec<EventConversion> {
|
||||
let mut conversions = Vec::new();
|
||||
let Some(raw_event) = event.get("event").and_then(Value::as_object) else {
|
||||
return conversions;
|
||||
};
|
||||
let event_type = raw_event.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
if event_type != "content_block_delta" {
|
||||
return conversions;
|
||||
}
|
||||
let delta_text = raw_event
|
||||
.get("delta")
|
||||
.and_then(|delta| delta.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
if delta_text.is_empty() {
|
||||
return conversions;
|
||||
}
|
||||
|
||||
conversions.push(EventConversion::new(
|
||||
UniversalEventType::ItemDelta,
|
||||
UniversalEventData::ItemDelta(ItemDeltaData {
|
||||
item_id: String::new(),
|
||||
native_item_id: None,
|
||||
delta: delta_text.to_string(),
|
||||
}),
|
||||
));
|
||||
|
||||
conversions
|
||||
}
|
||||
|
||||
fn control_request_to_universal(event: &Value) -> Result<Vec<EventConversion>, String> {
|
||||
let request_id = event
|
||||
.get("request_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "missing request_id".to_string())?;
|
||||
let request = event
|
||||
.get("request")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| "missing request".to_string())?;
|
||||
let subtype = request
|
||||
.get("subtype")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
|
||||
if subtype != "can_use_tool" {
|
||||
return Err(format!("unsupported Claude control_request subtype: {subtype}"));
|
||||
}
|
||||
|
||||
let tool_name = request
|
||||
.get("tool_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
||||
let permission_suggestions = request
|
||||
.get("permission_suggestions")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let blocked_path = request
|
||||
.get("blocked_path")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"toolName": tool_name,
|
||||
"input": input,
|
||||
"permissionSuggestions": permission_suggestions,
|
||||
"blockedPath": blocked_path,
|
||||
});
|
||||
|
||||
let permission = PermissionEventData {
|
||||
permission_id: request_id.to_string(),
|
||||
action: tool_name.to_string(),
|
||||
status: PermissionStatus::Requested,
|
||||
metadata: Some(metadata),
|
||||
};
|
||||
|
||||
Ok(vec![EventConversion::new(
|
||||
UniversalEventType::PermissionRequested,
|
||||
UniversalEventData::Permission(permission),
|
||||
)])
|
||||
}
|
||||
|
||||
fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConversion> {
|
||||
// The `result` event completes the message started by `assistant`.
|
||||
// Use the same native_item_id so they link to the same universal item.
|
||||
|
|
@ -285,7 +471,7 @@ fn item_events(item: UniversalItem, synthetic_start: bool) -> Vec<EventConversio
|
|||
|
||||
/// Emits item.started + item.delta only (for `assistant` event).
|
||||
/// The item.completed will come from the `result` event.
|
||||
fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<EventConversion> {
|
||||
fn message_started_events(item: UniversalItem) -> Vec<EventConversion> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Emit item.started (in-progress)
|
||||
|
|
@ -293,25 +479,6 @@ fn message_started_events(item: UniversalItem, parts: Vec<ContentPart>) -> Vec<E
|
|||
UniversalEventType::ItemStarted,
|
||||
UniversalEventData::Item(ItemEventData { item: item.clone() }),
|
||||
));
|
||||
|
||||
// Emit item.delta with the text content
|
||||
let mut delta_text = String::new();
|
||||
for part in &parts {
|
||||
if let ContentPart::Text { text } = part {
|
||||
delta_text.push_str(text);
|
||||
}
|
||||
}
|
||||
if !delta_text.is_empty() {
|
||||
events.push(EventConversion::new(
|
||||
UniversalEventType::ItemDelta,
|
||||
UniversalEventData::ItemDelta(crate::ItemDeltaData {
|
||||
item_id: item.item_id.clone(),
|
||||
native_item_id: item.native_item_id.clone(),
|
||||
delta: delta_text,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue