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                result: "ok".to_string(),
540                is_error: false,
541            })
542        }
543    }
544
545    #[test]
546    fn registers_roder_tools_as_sdk_mcp_server() {
547        let mut request = AgentInferenceRequest {
548            model: ModelSelection {
549                provider: "claude-code".to_string(),
550                model: "sonnet".to_string(),
551            },
552            instructions: InstructionBundle::default(),
553            transcript: Vec::new(),
554            tools: vec![ToolSpec {
555                name: "grep".to_string(),
556                description: "Search text".to_string(),
557                parameters: serde_json::json!({
558                    "type": "object",
559                    "properties": {"query": {"type": "string"}},
560                    "required": ["query"],
561                    "additionalProperties": false
562                }),
563            }],
564            tool_choice: ToolChoice::Auto,
565            reasoning: ReasoningConfig::default(),
566            output: OutputConfig::default(),
567            runtime: RuntimeHints {
568                hosted_web_search: HostedWebSearchConfig::disabled(),
569                ..RuntimeHints::default()
570            },
571            metadata: serde_json::json!({}),
572        };
573
574        let options = build_options(
575            &ClaudeCodeConfig::default(),
576            &request,
577            Some(Arc::new(FakeToolExecutor)),
578            None,
579            None,
580        )
581        .unwrap();
582
583        assert!(options.mcp_servers.contains_key("roder"));
584        assert_eq!(
585            options.allowed_tools,
586            vec!["mcp__roder__grep", "mcp__roder__Grep"]
587        );
588        let server = options.sdk_mcp_servers.get("roder").unwrap();
589        let mut names = server
590            .list_tools()
591            .into_iter()
592            .map(|tool| tool.name.clone())
593            .collect::<Vec<_>>();
594        names.sort();
595        assert_eq!(names, vec!["Grep", "grep"]);
596
597        // Built-in Claude Code tools must be disabled so the model uses the
598        // Roder MCP tools instead of bare built-ins (which would be denied).
599        assert!(options.tools_set);
600        assert!(options.tools.is_empty());
601
602        request.tool_choice = ToolChoice::None;
603        let options =
604            build_options(&ClaudeCodeConfig::default(), &request, None, None, None).unwrap();
605        assert!(options.sdk_mcp_servers.is_empty());
606        assert!(options.allowed_tools.is_empty());
607        // With no Roder tools advertised we leave the built-in tool set alone.
608        assert!(!options.tools_set);
609    }
610
611    fn chrome_request() -> AgentInferenceRequest {
612        AgentInferenceRequest {
613            model: ModelSelection {
614                provider: "claude-code".to_string(),
615                model: "opus".to_string(),
616            },
617            instructions: InstructionBundle::default(),
618            transcript: Vec::new(),
619            tools: Vec::new(),
620            tool_choice: ToolChoice::None,
621            reasoning: ReasoningConfig::default(),
622            output: OutputConfig::default(),
623            runtime: RuntimeHints {
624                hosted_web_search: HostedWebSearchConfig::disabled(),
625                ..RuntimeHints::default()
626            },
627            metadata: serde_json::json!({}),
628        }
629    }
630
631    #[test]
632    fn classifies_claude_in_chrome_tools() {
633        assert!(is_claude_in_chrome_tool("mcp__claude-in-chrome__navigate"));
634        assert!(is_claude_in_chrome_tool("mcp__Claude_in_Chrome__read_page"));
635        assert!(!is_claude_in_chrome_tool("mcp__roder__shell"));
636        assert!(!is_claude_in_chrome_tool("Bash"));
637    }
638
639    #[test]
640    fn chrome_disabled_sets_no_chrome_env() {
641        let options = build_options(
642            &ClaudeCodeConfig {
643                enable_claude_in_chrome: Some(false),
644                ..ClaudeCodeConfig::default()
645            },
646            &chrome_request(),
647            None,
648            None,
649            None,
650        )
651        .unwrap();
652        assert!(!options.env.contains_key("CLAUDE_CODE_ENABLE_CFC"));
653        assert!(!options.env.contains_key("ANTHROPIC_API_KEY"));
654        assert!(!options.env.contains_key("ANTHROPIC_AUTH_TOKEN"));
655    }
656
657    #[tokio::test]
658    async fn chrome_enabled_wires_cfc_env_and_authorizes_browser_tools() {
659        let options = build_options(
660            &ClaudeCodeConfig {
661                enable_claude_in_chrome: Some(true),
662                ..ClaudeCodeConfig::default()
663            },
664            &chrome_request(),
665            None,
666            None,
667            None,
668        )
669        .unwrap();
670        // The CLI is told to wire the Claude-in-Chrome MCP server and to ignore
671        // API-key auth so it falls back to subscription auth.
672        assert_eq!(
673            options
674                .env
675                .get("CLAUDE_CODE_ENABLE_CFC")
676                .map(String::as_str),
677            Some("1")
678        );
679        assert_eq!(
680            options.env.get("ANTHROPIC_API_KEY").map(String::as_str),
681            Some("")
682        );
683        assert_eq!(
684            options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
685            Some("")
686        );
687
688        let can_use_tool = options.can_use_tool.expect("permission callback set");
689        let allowed = can_use_tool
690            .call(
691                "mcp__claude-in-chrome__navigate".to_string(),
692                serde_json::Map::new(),
693                claude_code_sdk_rust::ToolPermissionContext::default(),
694            )
695            .await
696            .unwrap();
697        assert!(matches!(allowed, PermissionResult::Allow { .. }));
698    }
699
700    #[tokio::test]
701    async fn chrome_disabled_callback_denies_browser_tools() {
702        let options = build_options(
703            &ClaudeCodeConfig {
704                enable_claude_in_chrome: Some(false),
705                ..ClaudeCodeConfig::default()
706            },
707            &chrome_request(),
708            None,
709            None,
710            None,
711        )
712        .unwrap();
713        let can_use_tool = options.can_use_tool.expect("permission callback set");
714        let denied = can_use_tool
715            .call(
716                "mcp__claude-in-chrome__navigate".to_string(),
717                serde_json::Map::new(),
718                claude_code_sdk_rust::ToolPermissionContext::default(),
719            )
720            .await
721            .unwrap();
722        assert!(matches!(denied, PermissionResult::Deny { .. }));
723    }
724
725    #[test]
726    fn resume_session_id_is_passed_through_to_options() {
727        let request = AgentInferenceRequest {
728            model: ModelSelection {
729                provider: "claude-code".to_string(),
730                model: "sonnet".to_string(),
731            },
732            instructions: InstructionBundle::default(),
733            transcript: Vec::new(),
734            tools: Vec::new(),
735            tool_choice: ToolChoice::None,
736            reasoning: ReasoningConfig::default(),
737            output: OutputConfig::default(),
738            runtime: RuntimeHints {
739                hosted_web_search: HostedWebSearchConfig::disabled(),
740                ..RuntimeHints::default()
741            },
742            metadata: serde_json::json!({}),
743        };
744
745        let options = build_options(
746            &ClaudeCodeConfig::default(),
747            &request,
748            None,
749            None,
750            Some("session-123"),
751        )
752        .unwrap();
753        assert_eq!(options.resume.as_deref(), Some("session-123"));
754
755        // Blank/whitespace ids never resume a session.
756        let options = build_options(
757            &ClaudeCodeConfig::default(),
758            &request,
759            None,
760            None,
761            Some("  "),
762        )
763        .unwrap();
764        assert!(options.resume.is_none());
765    }
766
767    #[test]
768    fn repairs_raw_string_sdk_mcp_input_for_single_required_property() {
769        let schema = serde_json::json!({
770            "type": "object",
771            "properties": {
772                "path": { "type": "string" }
773            },
774            "required": ["path"],
775            "additionalProperties": false
776        });
777
778        assert_eq!(
779            repair_sdk_mcp_input_for_tool(
780                serde_json::json!("crates/roder-ext-claude-code"),
781                &schema,
782                "",
783                "",
784            ),
785            serde_json::json!({"path": "crates/roder-ext-claude-code"})
786        );
787        assert_eq!(
788            repair_sdk_mcp_input_for_tool(
789                serde_json::json!({"path": "Cargo.toml"}),
790                &schema,
791                "",
792                "",
793            ),
794            serde_json::json!({"path": "Cargo.toml"})
795        );
796        assert_eq!(
797            repair_sdk_mcp_input_for_tool(
798                serde_json::json!({"file_path": "README.md"}),
799                &schema,
800                "",
801                "",
802            ),
803            serde_json::json!({"path": "README.md"})
804        );
805    }
806
807    #[test]
808    fn repairs_array_sdk_mcp_input_for_command_property() {
809        let schema = serde_json::json!({
810            "type": "object",
811            "properties": {
812                "command": { "type": "string" }
813            },
814            "required": ["command"],
815            "additionalProperties": false
816        });
817
818        assert_eq!(
819            repair_sdk_mcp_input_for_tool(serde_json::json!(["ls", "-la"]), &schema, "", ""),
820            serde_json::json!({"command": "ls -la"})
821        );
822    }
823
824    #[test]
825    fn maps_claude_native_alias_inputs_to_roder_arguments() {
826        let grep_schema = serde_json::json!({
827            "type": "object",
828            "properties": {
829                "query": { "type": "string" },
830                "path": { "type": "string" }
831            },
832            "required": ["query"],
833            "additionalProperties": false
834        });
835        assert_eq!(
836            repair_sdk_mcp_input_for_tool(
837                serde_json::json!({"pattern": "TokenUsage", "path": "crates", "include": "*.rs"}),
838                &grep_schema,
839                "Grep",
840                "grep",
841            ),
842            serde_json::json!({"query": "TokenUsage", "path": "crates"})
843        );
844
845        let read_schema = serde_json::json!({
846            "type": "object",
847            "properties": {"path": {"type": "string"}},
848            "required": ["path"],
849            "additionalProperties": false
850        });
851        assert_eq!(
852            repair_sdk_mcp_input_for_tool(
853                serde_json::json!({"file_path": "README.md"}),
854                &read_schema,
855                "Read",
856                "read_file",
857            ),
858            serde_json::json!({"path": "README.md"})
859        );
860    }
861}