Skip to main content

roder_ext_claude_code/
options.rs

1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use claude_code_sdk_rust::mcp::{MCPContent, SdkMcpTool, SimpleMCPServer};
6use claude_code_sdk_rust::{
7    ClaudeAgentOptions, EffortLevel, PermissionMode, PermissionResult, SettingSource,
8};
9use roder_api::inference::{AgentInferenceRequest, ToolCallCompleted, TurnToolExecutor};
10use roder_api::tools::ToolChoice;
11
12use crate::provider::ClaudeCodeConfig;
13
14pub fn build_options(
15    config: &ClaudeCodeConfig,
16    request: &AgentInferenceRequest,
17    tool_executor: Option<Arc<dyn TurnToolExecutor>>,
18    cwd: Option<&Path>,
19    resume_session_id: Option<&str>,
20) -> anyhow::Result<ClaudeAgentOptions> {
21    let mut builder = ClaudeAgentOptions::builder()
22        .model(request.model.model.clone())
23        .include_partial_messages(true);
24    // Resume the persisted CLI session so the `claude` process keeps the prior
25    // conversation server-side and applies its own auto-compaction. When set,
26    // the provider only sends the new transcript tail as the prompt instead of
27    // replaying the whole transcript every turn (which is what overflowed the
28    // 1M context window with "Prompt is too long").
29    if let Some(session_id) = resume_session_id.filter(|value| !value.trim().is_empty()) {
30        builder = builder.resume(session_id.to_string());
31    }
32    if let Some(cli_path) = config
33        .cli_path
34        .as_deref()
35        .filter(|value| !value.trim().is_empty())
36    {
37        builder = builder.cli_path(cli_path.to_string());
38    }
39    if let Some(cwd) = cwd {
40        builder = builder.cwd(cwd.display().to_string());
41    }
42    if let Some(system) = merged_system_prompt(request) {
43        builder = builder.system_prompt(system);
44    }
45    if let Some(mode) = config.permission_mode.as_deref() {
46        builder = builder.permission_mode(parse_permission_mode(mode)?);
47    }
48    if let Some(setting_sources) = &config.setting_sources {
49        builder = builder.setting_sources(parse_setting_sources(setting_sources)?);
50    }
51    if request.reasoning.enabled
52        && let Some(level) = request.reasoning.level.as_deref()
53        && let Some(effort) = parse_effort_level(level)
54    {
55        builder = builder.effort(effort);
56    }
57    if let Some(max_tokens) = request.output.max_tokens {
58        let budget = i32::try_from(max_tokens).unwrap_or(i32::MAX);
59        builder = builder.max_thinking_tokens(budget);
60    }
61    // When the Claude-in-Chrome integration is enabled, force the CLI to wire
62    // its browser MCP server even in the SDK's headless/streaming mode and blank
63    // API-key auth so the CLI falls back to claude.ai subscription auth -- the
64    // Chrome integration only connects under subscription auth, and an inherited
65    // `ANTHROPIC_API_KEY` would otherwise take precedence and disable it.
66    let chrome_enabled = claude_in_chrome_enabled(config);
67    if chrome_enabled {
68        builder = builder
69            .env_var("CLAUDE_CODE_ENABLE_CFC", "1")
70            .env_var("ANTHROPIC_API_KEY", "")
71            .env_var("ANTHROPIC_AUTH_TOKEN", "");
72    }
73    let allowed_tool_names = allowed_claude_tool_names(request);
74    if !request.tools.is_empty() && !matches!(request.tool_choice, ToolChoice::None) {
75        let executor = tool_executor.ok_or_else(|| {
76            anyhow::anyhow!("Claude Code provider requires a Roder tool executor")
77        })?;
78        let server = roder_sdk_mcp_server(request, executor);
79        builder = builder.sdk_mcp_server("roder", server);
80        // Disable every built-in Claude Code tool (Read/Bash/Edit/Glob/...).
81        // Roder mediates all tool access through its own executor, so the
82        // model must use the `mcp__roder__*` tools we advertise. Leaving the
83        // built-ins enabled lets the model call e.g. bare `Read`, which our
84        // `can_use_tool` callback then denies -- burning retries until the
85        // turn trips the consecutive-tool-failure reliability limit.
86        builder = builder.tools(Vec::new());
87        builder = builder.allowed_tools(
88            allowed_tool_names
89                .iter()
90                .map(|name| format!("mcp__roder__{name}"))
91                .collect(),
92        );
93    }
94    builder = builder.can_use_tool(move |tool_name, _input, _context| {
95        let allowed_tool_names = allowed_tool_names.clone();
96        async move {
97            // Let the CLI execute its own Claude-in-Chrome browser tools when the
98            // integration is enabled; Roder does not mediate these (they drive
99            // the user's real browser through the CLI's native host).
100            if chrome_enabled && is_claude_in_chrome_tool(&tool_name) {
101                return Ok(PermissionResult::allow());
102            }
103            let Some(claude_tool_name) = tool_name.strip_prefix("mcp__roder__") else {
104                return Ok(PermissionResult::deny(format!(
105                    "Claude Code tool {tool_name} is not managed by Roder"
106                )));
107            };
108            if allowed_tool_names
109                .iter()
110                .any(|name| name == claude_tool_name)
111            {
112                Ok(PermissionResult::allow())
113            } else {
114                Ok(PermissionResult::deny(format!(
115                    "Claude Code tool {tool_name} was not advertised for this Roder turn"
116                )))
117            }
118        }
119    });
120    Ok(builder.build())
121}
122
123/// Tool-name prefixes for the Claude Code "Claude in Chrome" integration. The
124/// CLI exposes the browser surface under two server spellings (the Chrome
125/// extension and the desktop app), so both are recognized.
126const CLAUDE_IN_CHROME_TOOL_PREFIXES: [&str; 2] =
127    ["mcp__claude-in-chrome__", "mcp__Claude_in_Chrome__"];
128
129/// Whether `name` is a Claude-in-Chrome browser tool the CLI executes itself.
130fn is_claude_in_chrome_tool(name: &str) -> bool {
131    CLAUDE_IN_CHROME_TOOL_PREFIXES
132        .iter()
133        .any(|prefix| name.starts_with(prefix))
134}
135
136/// Resolve whether the Claude-in-Chrome integration should be enabled for this
137/// turn: explicit config wins, then the `RODER_CLAUDE_CODE_ENABLE_CHROME` /
138/// `CLAUDE_CODE_ENABLE_CHROME` env overrides, then auto-detection of a local
139/// Claude-in-Chrome setup.
140fn claude_in_chrome_enabled(config: &ClaudeCodeConfig) -> bool {
141    if let Some(explicit) = config.enable_claude_in_chrome {
142        return explicit;
143    }
144    if let Some(value) = env_flag("RODER_CLAUDE_CODE_ENABLE_CHROME")
145        .or_else(|| env_flag("CLAUDE_CODE_ENABLE_CHROME"))
146    {
147        return value;
148    }
149    detect_claude_in_chrome_setup()
150}
151
152/// Parse a boolean-ish environment flag. Returns `None` when unset or
153/// unrecognized so callers can fall back to the next signal.
154fn env_flag(key: &str) -> Option<bool> {
155    let raw = std::env::var(key).ok()?;
156    match raw.trim().to_ascii_lowercase().as_str() {
157        "1" | "true" | "yes" | "on" => Some(true),
158        "0" | "false" | "no" | "off" | "" => Some(false),
159        _ => None,
160    }
161}
162
163/// Best-effort detection of a local Claude-in-Chrome setup by inspecting the
164/// Claude Code config file (`$CLAUDE_CONFIG_DIR/.claude.json` or
165/// `~/.claude.json`). Returns false when the file is missing or unreadable.
166fn detect_claude_in_chrome_setup() -> bool {
167    let Some(path) = claude_config_path() else {
168        return false;
169    };
170    let Ok(text) = std::fs::read_to_string(&path) else {
171        return false;
172    };
173    let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
174        return false;
175    };
176    let default_enabled = value
177        .get("claudeInChromeDefaultEnabled")
178        .and_then(serde_json::Value::as_bool)
179        .unwrap_or(false);
180    let paired = value
181        .get("chromeExtension")
182        .and_then(|ext| ext.get("pairedDeviceId"))
183        .map(|id| !id.is_null())
184        .unwrap_or(false);
185    let installed = value
186        .get("cachedChromeExtensionInstalled")
187        .and_then(serde_json::Value::as_bool)
188        .unwrap_or(false);
189    default_enabled || paired || installed
190}
191
192/// Path to the Claude Code config JSON, honoring `CLAUDE_CONFIG_DIR`.
193fn claude_config_path() -> Option<std::path::PathBuf> {
194    if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
195        return Some(std::path::PathBuf::from(dir).join(".claude.json"));
196    }
197    let home = std::env::var_os("HOME")?;
198    Some(std::path::PathBuf::from(home).join(".claude.json"))
199}
200
201fn allowed_claude_tool_names(request: &AgentInferenceRequest) -> Vec<String> {
202    request
203        .tools
204        .iter()
205        .flat_map(|tool| {
206            let mut names = vec![tool.name.clone()];
207            names.extend(claude_aliases_for_roder_tool(&tool.name).map(str::to_string));
208            names
209        })
210        .collect()
211}
212
213fn roder_sdk_mcp_server(
214    request: &AgentInferenceRequest,
215    executor: Arc<dyn TurnToolExecutor>,
216) -> SimpleMCPServer {
217    let tools = request
218        .tools
219        .iter()
220        .flat_map(|spec| {
221            let mut names = vec![spec.name.clone()];
222            names.extend(claude_aliases_for_roder_tool(&spec.name).map(str::to_string));
223            names.into_iter().map(|claude_name| {
224                sdk_tool_for_spec(
225                    claude_name,
226                    spec.name.clone(),
227                    spec.description.clone(),
228                    spec.parameters.clone(),
229                    Arc::clone(&executor),
230                )
231            })
232        })
233        .collect();
234    claude_code_sdk_rust::mcp::create_sdk_mcp_server("roder", tools)
235}
236
237/// Monotonic counter making every claude-code tool-call id unique across the
238/// process, so repeated calls of the same tool render as distinct rows.
239static NEXT_TOOL_CALL_SEQ: AtomicU64 = AtomicU64::new(0);
240
241fn sdk_tool_for_spec(
242    claude_name: String,
243    roder_name: String,
244    description: String,
245    schema: serde_json::Value,
246    executor: Arc<dyn TurnToolExecutor>,
247) -> SdkMcpTool {
248    let call_schema = schema.clone();
249    SdkMcpTool::new(
250        claude_name.clone(),
251        description,
252        schema,
253        None,
254        move |input| {
255            let input =
256                repair_sdk_mcp_input_for_tool(input, &call_schema, &claude_name, &roder_name);
257            // Each invocation must carry a unique tool-call id. The TUI and
258            // runtime key tool-call rows by id, so reusing a name-derived id
259            // (e.g. `claude-code-Bash`) collapses every later call of the same
260            // tool into the first row and only the first one ever renders.
261            let seq = NEXT_TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed);
262            let call = ToolCallCompleted {
263                id: format!("claude-code-{claude_name}-{seq}"),
264                name: roder_name.clone(),
265                arguments: input.to_string(),
266            };
267            let handle = tokio::runtime::Handle::current();
268            let outcome = tokio::task::block_in_place(|| handle.block_on(executor.execute(call)));
269            match outcome {
270                Ok(outcome) => {
271                    let text = if outcome.is_error {
272                        format!("Tool returned an error:\n{}", outcome.result)
273                    } else {
274                        outcome.result
275                    };
276                    Ok(vec![MCPContent::Text { text }])
277                }
278                Err(err) => Err(err.to_string()),
279            }
280        },
281    )
282}
283
284fn claude_aliases_for_roder_tool(name: &str) -> impl Iterator<Item = &'static str> {
285    match name {
286        "shell" => ["Bash"].as_slice().iter().copied(),
287        "read_file" => ["Read"].as_slice().iter().copied(),
288        "list_files" => ["LS"].as_slice().iter().copied(),
289        "grep" => ["Grep"].as_slice().iter().copied(),
290        "glob" => ["Glob"].as_slice().iter().copied(),
291        "write_file" => ["Write"].as_slice().iter().copied(),
292        "edit" => ["Edit"].as_slice().iter().copied(),
293        "multi_edit" => ["MultiEdit"].as_slice().iter().copied(),
294        _ => [].as_slice().iter().copied(),
295    }
296}
297
298fn retain_schema_properties(
299    object: &mut serde_json::Map<String, serde_json::Value>,
300    schema: &serde_json::Value,
301) {
302    let Some(properties) = schema
303        .get("properties")
304        .and_then(serde_json::Value::as_object)
305    else {
306        return;
307    };
308    object.retain(|key, _| properties.contains_key(key));
309}
310
311fn repair_sdk_mcp_input_for_tool(
312    input: serde_json::Value,
313    schema: &serde_json::Value,
314    claude_name: &str,
315    roder_name: &str,
316) -> serde_json::Value {
317    if let Some(mut object) = input.as_object().cloned() {
318        normalize_claude_tool_input_aliases(&mut object, claude_name, roder_name);
319        normalize_sdk_mcp_aliases(&mut object, schema);
320        retain_schema_properties(&mut object, schema);
321        return serde_json::Value::Object(object);
322    }
323    let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) else {
324        return input;
325    };
326    let [only_required] = required.as_slice() else {
327        return input;
328    };
329    let Some(property) = only_required.as_str() else {
330        return input;
331    };
332    let value = if property == "command" {
333        command_value_from_sdk_mcp_input(input)
334    } else {
335        input
336    };
337    serde_json::json!({ property: value })
338}
339
340fn normalize_claude_tool_input_aliases(
341    object: &mut serde_json::Map<String, serde_json::Value>,
342    claude_name: &str,
343    roder_name: &str,
344) {
345    match (claude_name, roder_name) {
346        ("Bash", "shell") => move_key_if_missing(object, "command", "command"),
347        ("Read", "read_file") => move_key_if_missing(object, "file_path", "path"),
348        ("LS", "list_files") => move_key_if_missing(object, "path", "path"),
349        ("Grep", "grep") => {
350            move_key_if_missing(object, "pattern", "query");
351            move_key_if_missing(object, "path", "path");
352            move_key_if_missing(object, "glob", "glob");
353        }
354        ("Glob", "glob") => move_key_if_missing(object, "pattern", "pattern"),
355        ("Write", "write_file") => move_key_if_missing(object, "file_path", "path"),
356        ("Edit", "edit") | ("MultiEdit", "multi_edit") => {
357            move_key_if_missing(object, "file_path", "path")
358        }
359        _ => {}
360    }
361}
362
363fn move_key_if_missing(
364    object: &mut serde_json::Map<String, serde_json::Value>,
365    from: &str,
366    to: &str,
367) {
368    if !object.contains_key(to)
369        && let Some(value) = object.remove(from)
370    {
371        object.insert(to.to_string(), value);
372    }
373}
374
375fn command_value_from_sdk_mcp_input(input: serde_json::Value) -> serde_json::Value {
376    let Some(items) = input.as_array() else {
377        return input;
378    };
379    let command = items
380        .iter()
381        .map(|item| match item {
382            serde_json::Value::String(text) => text.clone(),
383            other => other.to_string(),
384        })
385        .collect::<Vec<_>>()
386        .join(" ");
387    serde_json::Value::String(command)
388}
389
390fn normalize_sdk_mcp_aliases(
391    object: &mut serde_json::Map<String, serde_json::Value>,
392    schema: &serde_json::Value,
393) {
394    if schema
395        .get("properties")
396        .and_then(serde_json::Value::as_object)
397        .is_some_and(|properties| properties.contains_key("path"))
398        && !object.contains_key("path")
399        && let Some(value) = object.remove("file_path")
400    {
401        object.insert("path".to_string(), value);
402    }
403}
404
405fn merged_system_prompt(request: &AgentInferenceRequest) -> Option<String> {
406    match (
407        request.instructions.system.as_deref(),
408        request.instructions.developer.as_deref(),
409    ) {
410        (Some(system), Some(developer)) => Some(format!("{system}\n\n{developer}")),
411        (Some(system), None) => Some(system.to_string()),
412        (None, Some(developer)) => Some(developer.to_string()),
413        (None, None) => None,
414    }
415}
416
417/// Maps a Roder reasoning level to the SDK's `EffortLevel`, returning `None`
418/// for unrecognized values so the `--effort` flag is simply omitted.
419fn parse_effort_level(value: &str) -> Option<EffortLevel> {
420    match value.trim().to_ascii_lowercase().as_str() {
421        "low" | "minimal" => Some(EffortLevel::Low),
422        "medium" => Some(EffortLevel::Medium),
423        "high" => Some(EffortLevel::High),
424        "xhigh" | "very-high" | "veryhigh" => Some(EffortLevel::Xhigh),
425        "max" | "maximum" => Some(EffortLevel::Max),
426        _ => None,
427    }
428}
429
430pub fn parse_permission_mode(value: &str) -> anyhow::Result<PermissionMode> {
431    match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
432        "default" => Ok(PermissionMode::Default),
433        "accept-edits" | "acceptedits" => Ok(PermissionMode::AcceptEdits),
434        "plan" => Ok(PermissionMode::Plan),
435        "bypass-permissions" | "bypasspermissions" => Ok(PermissionMode::BypassPermissions),
436        "dont-ask" | "dontask" => Ok(PermissionMode::DontAsk),
437        "auto" => Ok(PermissionMode::Auto),
438        other => anyhow::bail!(
439            "unsupported Claude Code permission mode {other:?}; expected default, accept-edits, plan, bypass-permissions, dont-ask, or auto"
440        ),
441    }
442}
443
444pub fn parse_setting_sources(values: &[String]) -> anyhow::Result<Vec<SettingSource>> {
445    values
446        .iter()
447        .map(|value| match value.trim().to_ascii_lowercase().as_str() {
448            "user" => Ok(SettingSource::User),
449            "project" => Ok(SettingSource::Project),
450            "local" => Ok(SettingSource::Local),
451            other => anyhow::bail!(
452                "unsupported Claude Code setting source {other:?}; expected user, project, or local"
453            ),
454        })
455        .collect()
456}
457
458#[cfg(test)]
459mod tests {
460    use roder_api::inference::{
461        AgentInferenceRequest, HostedWebSearchConfig, InstructionBundle, ModelSelection,
462        OutputConfig, ReasoningConfig, RuntimeHints, TurnToolOutcome,
463    };
464    use roder_api::tools::{ToolChoice, ToolSpec};
465
466    use super::*;
467
468    #[test]
469    fn parses_permission_modes() {
470        assert!(matches!(
471            parse_permission_mode("accept-edits").unwrap(),
472            PermissionMode::AcceptEdits
473        ));
474        assert!(parse_permission_mode("root").is_err());
475    }
476
477    #[test]
478    fn builds_options_without_running_claude() {
479        let request = AgentInferenceRequest {
480            model: ModelSelection {
481                provider: "claude-code".to_string(),
482                model: "sonnet".to_string(),
483            },
484            instructions: InstructionBundle {
485                system: Some("system".to_string()),
486                developer: Some("developer".to_string()),
487                developer_context: None,
488            },
489            transcript: Vec::new(),
490            tools: Vec::new(),
491            tool_choice: ToolChoice::Auto,
492            reasoning: ReasoningConfig {
493                enabled: true,
494                level: Some("medium".to_string()),
495            },
496            output: OutputConfig {
497                max_tokens: Some(1024),
498                temperature: None,
499                top_p: None,
500                response_format: None,
501            },
502            runtime: RuntimeHints {
503                hosted_web_search: HostedWebSearchConfig::disabled(),
504                ..RuntimeHints::default()
505            },
506            metadata: serde_json::json!({}),
507        };
508        let options = build_options(
509            &ClaudeCodeConfig {
510                cli_path: Some("/bin/claude".to_string()),
511                permission_mode: Some("default".to_string()),
512                setting_sources: Some(vec!["user".to_string(), "project".to_string()]),
513                workspace: None,
514                reuse_cli_session: None,
515                enable_claude_in_chrome: Some(false),
516            },
517            &request,
518            None,
519            None,
520            None,
521        )
522        .unwrap();
523        assert_eq!(options.model.as_deref(), Some("sonnet"));
524        assert_eq!(options.cli_path.as_deref(), Some("/bin/claude"));
525        assert_eq!(
526            options.system_prompt.as_deref(),
527            Some("system\n\ndeveloper")
528        );
529        assert_eq!(options.effort.map(|effort| effort.as_cli()), Some("medium"));
530        assert!(options.can_use_tool.is_some());
531    }
532
533    struct FakeToolExecutor;
534
535    #[async_trait::async_trait]
536    impl TurnToolExecutor for FakeToolExecutor {
537        async fn execute(&self, _call: ToolCallCompleted) -> anyhow::Result<TurnToolOutcome> {
538            Ok(TurnToolOutcome {
539                data: serde_json::Value::Null,
540                result: "ok".to_string(),
541                is_error: false,
542            })
543        }
544    }
545
546    #[test]
547    fn registers_roder_tools_as_sdk_mcp_server() {
548        let mut request = AgentInferenceRequest {
549            model: ModelSelection {
550                provider: "claude-code".to_string(),
551                model: "sonnet".to_string(),
552            },
553            instructions: InstructionBundle::default(),
554            transcript: Vec::new(),
555            tools: vec![ToolSpec {
556                name: "grep".to_string(),
557                description: "Search text".to_string(),
558                parameters: serde_json::json!({
559                    "type": "object",
560                    "properties": {"query": {"type": "string"}},
561                    "required": ["query"],
562                    "additionalProperties": false
563                }),
564            }],
565            tool_choice: ToolChoice::Auto,
566            reasoning: ReasoningConfig::default(),
567            output: OutputConfig::default(),
568            runtime: RuntimeHints {
569                hosted_web_search: HostedWebSearchConfig::disabled(),
570                ..RuntimeHints::default()
571            },
572            metadata: serde_json::json!({}),
573        };
574
575        let options = build_options(
576            &ClaudeCodeConfig::default(),
577            &request,
578            Some(Arc::new(FakeToolExecutor)),
579            None,
580            None,
581        )
582        .unwrap();
583
584        assert!(options.mcp_servers.contains_key("roder"));
585        assert_eq!(
586            options.allowed_tools,
587            vec!["mcp__roder__grep", "mcp__roder__Grep"]
588        );
589        let server = options.sdk_mcp_servers.get("roder").unwrap();
590        let mut names = server
591            .list_tools()
592            .into_iter()
593            .map(|tool| tool.name.clone())
594            .collect::<Vec<_>>();
595        names.sort();
596        assert_eq!(names, vec!["Grep", "grep"]);
597
598        // Built-in Claude Code tools must be disabled so the model uses the
599        // Roder MCP tools instead of bare built-ins (which would be denied).
600        assert!(options.tools_set);
601        assert!(options.tools.is_empty());
602
603        request.tool_choice = ToolChoice::None;
604        let options =
605            build_options(&ClaudeCodeConfig::default(), &request, None, None, None).unwrap();
606        assert!(options.sdk_mcp_servers.is_empty());
607        assert!(options.allowed_tools.is_empty());
608        // With no Roder tools advertised we leave the built-in tool set alone.
609        assert!(!options.tools_set);
610    }
611
612    fn chrome_request() -> AgentInferenceRequest {
613        AgentInferenceRequest {
614            model: ModelSelection {
615                provider: "claude-code".to_string(),
616                model: "opus".to_string(),
617            },
618            instructions: InstructionBundle::default(),
619            transcript: Vec::new(),
620            tools: Vec::new(),
621            tool_choice: ToolChoice::None,
622            reasoning: ReasoningConfig::default(),
623            output: OutputConfig::default(),
624            runtime: RuntimeHints {
625                hosted_web_search: HostedWebSearchConfig::disabled(),
626                ..RuntimeHints::default()
627            },
628            metadata: serde_json::json!({}),
629        }
630    }
631
632    #[test]
633    fn classifies_claude_in_chrome_tools() {
634        assert!(is_claude_in_chrome_tool("mcp__claude-in-chrome__navigate"));
635        assert!(is_claude_in_chrome_tool("mcp__Claude_in_Chrome__read_page"));
636        assert!(!is_claude_in_chrome_tool("mcp__roder__shell"));
637        assert!(!is_claude_in_chrome_tool("Bash"));
638    }
639
640    #[test]
641    fn chrome_disabled_sets_no_chrome_env() {
642        let options = build_options(
643            &ClaudeCodeConfig {
644                enable_claude_in_chrome: Some(false),
645                ..ClaudeCodeConfig::default()
646            },
647            &chrome_request(),
648            None,
649            None,
650            None,
651        )
652        .unwrap();
653        assert!(!options.env.contains_key("CLAUDE_CODE_ENABLE_CFC"));
654        assert!(!options.env.contains_key("ANTHROPIC_API_KEY"));
655        assert!(!options.env.contains_key("ANTHROPIC_AUTH_TOKEN"));
656    }
657
658    #[tokio::test]
659    async fn chrome_enabled_wires_cfc_env_and_authorizes_browser_tools() {
660        let options = build_options(
661            &ClaudeCodeConfig {
662                enable_claude_in_chrome: Some(true),
663                ..ClaudeCodeConfig::default()
664            },
665            &chrome_request(),
666            None,
667            None,
668            None,
669        )
670        .unwrap();
671        // The CLI is told to wire the Claude-in-Chrome MCP server and to ignore
672        // API-key auth so it falls back to subscription auth.
673        assert_eq!(
674            options
675                .env
676                .get("CLAUDE_CODE_ENABLE_CFC")
677                .map(String::as_str),
678            Some("1")
679        );
680        assert_eq!(
681            options.env.get("ANTHROPIC_API_KEY").map(String::as_str),
682            Some("")
683        );
684        assert_eq!(
685            options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
686            Some("")
687        );
688
689        let can_use_tool = options.can_use_tool.expect("permission callback set");
690        let allowed = can_use_tool
691            .call(
692                "mcp__claude-in-chrome__navigate".to_string(),
693                serde_json::Map::new(),
694                claude_code_sdk_rust::ToolPermissionContext::default(),
695            )
696            .await
697            .unwrap();
698        assert!(matches!(allowed, PermissionResult::Allow { .. }));
699    }
700
701    #[tokio::test]
702    async fn chrome_disabled_callback_denies_browser_tools() {
703        let options = build_options(
704            &ClaudeCodeConfig {
705                enable_claude_in_chrome: Some(false),
706                ..ClaudeCodeConfig::default()
707            },
708            &chrome_request(),
709            None,
710            None,
711            None,
712        )
713        .unwrap();
714        let can_use_tool = options.can_use_tool.expect("permission callback set");
715        let denied = can_use_tool
716            .call(
717                "mcp__claude-in-chrome__navigate".to_string(),
718                serde_json::Map::new(),
719                claude_code_sdk_rust::ToolPermissionContext::default(),
720            )
721            .await
722            .unwrap();
723        assert!(matches!(denied, PermissionResult::Deny { .. }));
724    }
725
726    #[test]
727    fn resume_session_id_is_passed_through_to_options() {
728        let request = AgentInferenceRequest {
729            model: ModelSelection {
730                provider: "claude-code".to_string(),
731                model: "sonnet".to_string(),
732            },
733            instructions: InstructionBundle::default(),
734            transcript: Vec::new(),
735            tools: Vec::new(),
736            tool_choice: ToolChoice::None,
737            reasoning: ReasoningConfig::default(),
738            output: OutputConfig::default(),
739            runtime: RuntimeHints {
740                hosted_web_search: HostedWebSearchConfig::disabled(),
741                ..RuntimeHints::default()
742            },
743            metadata: serde_json::json!({}),
744        };
745
746        let options = build_options(
747            &ClaudeCodeConfig::default(),
748            &request,
749            None,
750            None,
751            Some("session-123"),
752        )
753        .unwrap();
754        assert_eq!(options.resume.as_deref(), Some("session-123"));
755
756        // Blank/whitespace ids never resume a session.
757        let options = build_options(
758            &ClaudeCodeConfig::default(),
759            &request,
760            None,
761            None,
762            Some("  "),
763        )
764        .unwrap();
765        assert!(options.resume.is_none());
766    }
767
768    #[test]
769    fn repairs_raw_string_sdk_mcp_input_for_single_required_property() {
770        let schema = serde_json::json!({
771            "type": "object",
772            "properties": {
773                "path": { "type": "string" }
774            },
775            "required": ["path"],
776            "additionalProperties": false
777        });
778
779        assert_eq!(
780            repair_sdk_mcp_input_for_tool(
781                serde_json::json!("crates/roder-ext-claude-code"),
782                &schema,
783                "",
784                "",
785            ),
786            serde_json::json!({"path": "crates/roder-ext-claude-code"})
787        );
788        assert_eq!(
789            repair_sdk_mcp_input_for_tool(
790                serde_json::json!({"path": "Cargo.toml"}),
791                &schema,
792                "",
793                "",
794            ),
795            serde_json::json!({"path": "Cargo.toml"})
796        );
797        assert_eq!(
798            repair_sdk_mcp_input_for_tool(
799                serde_json::json!({"file_path": "README.md"}),
800                &schema,
801                "",
802                "",
803            ),
804            serde_json::json!({"path": "README.md"})
805        );
806    }
807
808    #[test]
809    fn repairs_array_sdk_mcp_input_for_command_property() {
810        let schema = serde_json::json!({
811            "type": "object",
812            "properties": {
813                "command": { "type": "string" }
814            },
815            "required": ["command"],
816            "additionalProperties": false
817        });
818
819        assert_eq!(
820            repair_sdk_mcp_input_for_tool(serde_json::json!(["ls", "-la"]), &schema, "", ""),
821            serde_json::json!({"command": "ls -la"})
822        );
823    }
824
825    #[test]
826    fn maps_claude_native_alias_inputs_to_roder_arguments() {
827        let grep_schema = serde_json::json!({
828            "type": "object",
829            "properties": {
830                "query": { "type": "string" },
831                "path": { "type": "string" }
832            },
833            "required": ["query"],
834            "additionalProperties": false
835        });
836        assert_eq!(
837            repair_sdk_mcp_input_for_tool(
838                serde_json::json!({"pattern": "TokenUsage", "path": "crates", "include": "*.rs"}),
839                &grep_schema,
840                "Grep",
841                "grep",
842            ),
843            serde_json::json!({"query": "TokenUsage", "path": "crates"})
844        );
845
846        let read_schema = serde_json::json!({
847            "type": "object",
848            "properties": {"path": {"type": "string"}},
849            "required": ["path"],
850            "additionalProperties": false
851        });
852        assert_eq!(
853            repair_sdk_mcp_input_for_tool(
854                serde_json::json!({"file_path": "README.md"}),
855                &read_schema,
856                "Read",
857                "read_file",
858            ),
859            serde_json::json!({"path": "README.md"})
860        );
861    }
862}