agentmux_cef\commands/
platform.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Platform info commands for the CEF host.
5// Ported from src-tauri/src/commands/platform.rs without Tauri dependencies.
6
7use std::io::Read;
8use std::sync::Arc;
9
10use crate::state::AppState;
11
12const SETTINGS_TEMPLATE: &str = include_str!("../../../settings-template.jsonc");
13
14/// Get the current OS platform name.
15pub fn get_platform() -> serde_json::Value {
16    let platform = match std::env::consts::OS {
17        "macos" => "darwin",
18        "windows" => "win32",
19        other => other,
20    };
21    serde_json::json!(platform)
22}
23
24/// Get the current user's username.
25pub fn get_user_name() -> serde_json::Value {
26    serde_json::json!(whoami::username())
27}
28
29/// Get the system hostname.
30pub fn get_host_name() -> serde_json::Value {
31    let hostname = whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string());
32    serde_json::json!(hostname)
33}
34
35/// Check if running in development mode — resolved from the runtime
36/// `RuntimeMode` (launcher-injected env, or the host exe path).
37pub fn get_is_dev() -> serde_json::Value {
38    let mode = agentmux_common::RuntimeMode::from_env().or_else(|| {
39        std::env::current_exe()
40            .ok()
41            .and_then(|p| p.parent().map(|d| d.to_path_buf()))
42            .map(|d| agentmux_common::RuntimeMode::current(&d))
43    });
44    serde_json::json!(matches!(mode, Some(agentmux_common::RuntimeMode::Dev { .. })))
45}
46
47/// Get the app data directory path (version-specific).
48pub fn get_data_dir(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
49    let dir = state.version_data_dir.lock();
50    match dir.as_ref() {
51        Some(d) => Ok(serde_json::json!(d)),
52        None => Err("Data dir not initialized yet".to_string()),
53    }
54}
55
56/// Get the app config directory path (version-specific).
57pub fn get_config_dir(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
58    let dir = state.version_config_dir.lock();
59    match dir.as_ref() {
60        Some(d) => Ok(serde_json::json!(d)),
61        None => Err("Config dir not initialized yet".to_string()),
62    }
63}
64
65/// Get the user home directory used by the frontend for per-agent paths
66/// (working dir, `GH_CONFIG_DIR`, etc.).
67///
68/// Portable returns `<portable>/data`; installed returns `~/.agentmux`;
69/// `AGENTMUX_DATA_HOME`, if set at launch, overrides both.
70/// See `docs/specs/portable-agent-working-dirs.md`.
71pub fn get_user_home_dir(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
72    let dir = state.user_home_dir.lock();
73    match dir.as_ref() {
74        Some(d) => Ok(serde_json::json!(d)),
75        None => Err("User home dir not initialized yet".to_string()),
76    }
77}
78
79/// Ensure a provider auth directory exists and return its absolute path.
80/// Auth dirs are version-isolated under the version-specific config dir.
81pub fn ensure_auth_dir(
82    state: &Arc<AppState>,
83    args: &serde_json::Value,
84) -> Result<serde_json::Value, String> {
85    let provider_id = args
86        .get("provider_id")
87        .or_else(|| args.get("providerId"))
88        .and_then(|v| v.as_str())
89        .ok_or_else(|| "Missing provider_id".to_string())?;
90
91    // Reject path traversal attempts in provider_id
92    if provider_id.contains('/')
93        || provider_id.contains('\\')
94        || provider_id.contains("..")
95        || provider_id.is_empty()
96    {
97        return Err(format!(
98            "Invalid provider_id '{}': must not contain path separators or '..'",
99            provider_id
100        ));
101    }
102
103    let config_dir = state.version_config_dir.lock();
104    let config_dir = config_dir
105        .as_ref()
106        .ok_or_else(|| "Config dir not initialized yet".to_string())?;
107
108    let auth_dir = std::path::PathBuf::from(config_dir)
109        .join("auth")
110        .join(provider_id);
111    std::fs::create_dir_all(&auth_dir)
112        .map_err(|e| format!("Failed to create auth dir for {}: {}", provider_id, e))?;
113
114    Ok(serde_json::json!(auth_dir.to_string_lossy()))
115}
116
117/// Get an environment variable value.
118pub fn get_env(args: &serde_json::Value) -> serde_json::Value {
119    let key = args
120        .get("key")
121        .and_then(|v| v.as_str())
122        .unwrap_or_default();
123    match std::env::var(key) {
124        Ok(val) => serde_json::json!(val),
125        Err(_) => serde_json::Value::Null,
126    }
127}
128
129/// Get details for the About modal.
130pub fn get_about_modal_details(state: &Arc<AppState>) -> serde_json::Value {
131    let version = env!("CARGO_PKG_VERSION");
132    let endpoints = state.backend_endpoints.lock();
133
134    serde_json::json!({
135        "version": version,
136        "gitHash": env!("AGENTMUX_GIT_HASH"),
137        "buildTime": env!("AGENTMUX_BUILD_TIME").parse::<i64>().unwrap_or(0),
138        "platform": match std::env::consts::OS {
139            "macos" => "darwin",
140            "windows" => "win32",
141            other => other,
142        },
143        "arch": std::env::consts::ARCH,
144        "backendEndpoints": {
145            "ws": endpoints.ws_endpoint,
146            "web": endpoints.web_endpoint,
147        }
148    })
149}
150
151/// Get comprehensive host info for the hostname popover.
152pub fn get_host_info(state: &Arc<AppState>) -> serde_json::Value {
153    let version = env!("CARGO_PKG_VERSION");
154    let endpoints = state.backend_endpoints.lock();
155    let ipc_port = *state.ipc_port.lock();
156    let data_dir = state.version_data_dir.lock().clone().unwrap_or_default();
157    let pid = std::process::id();
158
159    // Resolve primary local IP
160    let local_ip = local_ip_address().unwrap_or_else(|| "127.0.0.1".to_string());
161
162    let os_info = format!("{} {}",
163        match std::env::consts::OS {
164            "windows" => "Windows",
165            "macos" => "macOS",
166            "linux" => "Linux",
167            other => other,
168        },
169        std::env::consts::ARCH
170    );
171
172    serde_json::json!({
173        "hostname": whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string()),
174        "os": os_info,
175        "localIp": local_ip,
176        "instanceId": format!("v{}", version),
177        "version": version,
178        "dataDir": data_dir,
179        "hostType": "CEF 146",
180        "pid": pid,
181        "ports": {
182            "ipc": format!("127.0.0.1:{}", ipc_port),
183            "web": endpoints.web_endpoint,
184            "ws": endpoints.ws_endpoint,
185            "devtools": "127.0.0.1:9222",
186        }
187    })
188}
189
190/// Get the primary non-loopback IPv4 address.
191fn local_ip_address() -> Option<String> {
192    // Connect a UDP socket to an external address to determine the local IP
193    // (doesn't actually send data — just resolves the route)
194    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
195    socket.connect("8.8.8.8:80").ok()?;
196    let addr = socket.local_addr().ok()?;
197    Some(addr.ip().to_string())
198}
199
200/// Get the documentation site URL.
201pub fn get_docsite_url(state: &Arc<AppState>) -> serde_json::Value {
202    let endpoints = state.backend_endpoints.lock();
203    if !endpoints.web_endpoint.is_empty() {
204        serde_json::json!(format!("http://{}/docsite/", endpoints.web_endpoint))
205    } else {
206        serde_json::json!("https://docs.agentmux.ai")
207    }
208}
209
210/// Open a file in the best available code editor.
211pub fn open_in_editor(args: &serde_json::Value) -> Result<serde_json::Value, String> {
212    let path = args
213        .get("path")
214        .and_then(|v| v.as_str())
215        .ok_or_else(|| "Missing path".to_string())?;
216
217    #[cfg(target_os = "windows")]
218    {
219        // Use explorer.exe directly instead of cmd /C start to avoid shell injection.
220        std::process::Command::new("explorer")
221            .arg(path)
222            .spawn()
223            .map_err(|e| format!("Failed to open file: {}", e))?;
224        return Ok(serde_json::Value::Null);
225    }
226
227    #[cfg(not(target_os = "windows"))]
228    {
229        let cli_editors = ["code", "cursor", "zed", "subl", "atom"];
230        for editor in &cli_editors {
231            if std::process::Command::new(editor).arg(path).spawn().is_ok() {
232                return Ok(serde_json::Value::Null);
233            }
234        }
235    }
236
237    #[cfg(target_os = "macos")]
238    {
239        std::process::Command::new("open")
240            .arg(path)
241            .spawn()
242            .map_err(|e| e.to_string())?;
243        return Ok(serde_json::Value::Null);
244    }
245    #[cfg(target_os = "linux")]
246    {
247        std::process::Command::new("xdg-open")
248            .arg(path)
249            .spawn()
250            .map_err(|e| e.to_string())?;
251        return Ok(serde_json::Value::Null);
252    }
253
254    #[allow(unreachable_code)]
255    Ok(serde_json::Value::Null)
256}
257
258/// Ensure settings.json exists in the config directory with the latest template.
259pub fn ensure_settings_file(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
260    let config_dir_str = state
261        .version_config_dir
262        .lock()
263        .clone()
264        .ok_or_else(|| "Config dir not initialized yet".to_string())?;
265    let config_dir = std::path::PathBuf::from(&config_dir_str);
266
267    std::fs::create_dir_all(&config_dir)
268        .map_err(|e| format!("Failed to create config dir: {}", e))?;
269
270    let settings_path = config_dir.join("settings.json");
271
272    // Read existing user values (strips JSONC comments, parses JSON)
273    let existing = read_settings_jsonc(&settings_path);
274
275    // Merge user values into fresh template
276    let merged = merge_into_template(SETTINGS_TEMPLATE, &existing);
277    std::fs::write(&settings_path, &merged)
278        .map_err(|e| format!("Failed to write settings.json: {}", e))?;
279
280    Ok(serde_json::json!(settings_path.to_string_lossy()))
281}
282
283/// Stdin handle for an in-progress CLI login, regardless of whether
284/// it was spawned via plain pipes or via a PTY. `set_provider_auth`
285/// writes the OAuth code / pasted token here.
286pub enum CliLoginStdin {
287    /// Plain pipe — `tokio::process::Command` with `Stdio::piped()`.
288    /// AsyncWrite via tokio. Used by Claude, Codex, Gemini, Copilot,
289    /// Kimi — anything that doesn't strictly require a TTY for its
290    /// auth subcommand.
291    Pipe(tokio::process::ChildStdin),
292    /// PTY writer — `portable_pty` master writer. Sync `std::io::Write`.
293    /// Used by providers whose auth subcommand bails on `isatty()==0`
294    /// (currently OpenClaw's `openclaw models auth login`).
295    Pty(Box<dyn std::io::Write + Send>),
296}
297
298impl CliLoginStdin {
299    /// Write a line (terminated with `\n`) to the child's stdin. Used
300    /// by `set_provider_auth` to deliver an OAuth code.
301    pub async fn write_line(&mut self, line: &str) -> std::io::Result<()> {
302        let payload = format!("{}\n", line);
303        match self {
304            CliLoginStdin::Pipe(s) => {
305                use tokio::io::AsyncWriteExt;
306                s.write_all(payload.as_bytes()).await?;
307                s.flush().await?;
308                Ok(())
309            }
310            CliLoginStdin::Pty(w) => {
311                use std::io::Write;
312                // portable_pty's master writer is sync. Run it via
313                // `block_in_place` so the brief sync write doesn't
314                // starve the tokio reactor on the current worker
315                // thread if the PTY input buffer is full.
316                tokio::task::block_in_place(|| {
317                    w.write_all(payload.as_bytes())?;
318                    w.flush()
319                })
320            }
321        }
322    }
323}
324
325/// Spawn a CLI auth login flow.
326pub async fn run_cli_login(
327    state: Arc<AppState>,
328    args: &serde_json::Value,
329) -> Result<serde_json::Value, String> {
330    let cli_path = args
331        .get("cli_path")
332        .or_else(|| args.get("cliPath"))
333        .and_then(|v| v.as_str())
334        .ok_or_else(|| "Missing cli_path".to_string())?
335        .to_string();
336
337    let login_args: Vec<String> = args
338        .get("login_args")
339        .or_else(|| args.get("loginArgs"))
340        .and_then(|v| v.as_array())
341        .map(|arr| {
342            arr.iter()
343                .filter_map(|v| v.as_str().map(|s| s.to_string()))
344                .collect()
345        })
346        .unwrap_or_default();
347
348    let auth_env: std::collections::HashMap<String, String> = args
349        .get("auth_env")
350        .or_else(|| args.get("authEnv"))
351        .and_then(|v| v.as_object())
352        .map(|obj| {
353            obj.iter()
354                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
355                .collect()
356        })
357        .unwrap_or_default();
358
359    // `requires_tty` (passed by the frontend from the provider config)
360    // selects the PTY-spawn branch below. Providers like OpenClaw
361    // strictly require an interactive TTY for their auth subcommand —
362    // plain piped stdio causes the CLI to exit with
363    // "requires an interactive TTY" before printing the OAuth URL.
364    let requires_tty = args
365        .get("requires_tty")
366        .or_else(|| args.get("requiresTty"))
367        .and_then(|v| v.as_bool())
368        .unwrap_or(false);
369
370    if requires_tty {
371        return run_cli_login_pty(state, cli_path, login_args, auth_env).await;
372    }
373
374    let mut cmd = make_cli_cmd(&cli_path);
375    cmd.args(&login_args)
376        .envs(&auth_env)
377        .stdin(std::process::Stdio::piped())
378        .stdout(std::process::Stdio::piped())
379        .stderr(std::process::Stdio::piped());
380
381    #[cfg(windows)]
382    {
383        cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
384    }
385
386    let mut child = cmd
387        .spawn()
388        .map_err(|e| format!("failed to spawn {cli_path}: {e}"))?;
389
390    tracing::info!(cli = %cli_path, "run_cli_login: spawned (pipes), browser should open");
391
392    // Store the stdin handle so set_provider_auth can deliver the OAuth code.
393    {
394        let mut stored_stdin = state.cli_login_stdin.lock();
395        *stored_stdin = child.stdin.take().map(CliLoginStdin::Pipe);
396    }
397
398    // Capture the OAuth URL from stdout/stderr. The CLI prints it within the
399    // first few hundred ms after spawn. We read until we find "https://..."
400    // or time out after 2s.
401    let stdout = child.stdout.take();
402    let stderr = child.stderr.take();
403
404    let auth_url: Option<String> = tokio::time::timeout(
405        std::time::Duration::from_secs(2),
406        async {
407            use tokio::io::AsyncBufReadExt;
408            let mut combined = Vec::new();
409            if let Some(s) = stdout {
410                let mut lines = tokio::io::BufReader::new(s).lines();
411                while let Ok(Some(line)) = lines.next_line().await {
412                    if let Some(url) = extract_url(&line) {
413                        return Some(url);
414                    }
415                    combined.push(line);
416                    if combined.len() > 20 { break; }
417                }
418            }
419            if let Some(s) = stderr {
420                let mut lines = tokio::io::BufReader::new(s).lines();
421                while let Ok(Some(line)) = lines.next_line().await {
422                    if let Some(url) = extract_url(&line) {
423                        return Some(url);
424                    }
425                    if combined.len() > 40 { break; }
426                    combined.push(line);
427                }
428            }
429            None
430        },
431    ).await.unwrap_or(None);
432
433    if let Some(ref url) = auth_url {
434        tracing::info!(url = %url, "run_cli_login: captured auth URL");
435    } else {
436        tracing::warn!("run_cli_login: no auth URL captured within 2s");
437    }
438
439    let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
440    {
441        let mut stored = state.cli_login_cancel.lock();
442        *stored = Some(cancel_tx);
443    }
444
445    let state_for_cleanup = state.clone();
446    tokio::spawn(async move {
447        tokio::select! {
448            result = child.wait() => {
449                match result {
450                    Ok(status) => tracing::info!(
451                        exit_code = ?status.code(),
452                        "run_cli_login: child exited"
453                    ),
454                    Err(e) => tracing::warn!(
455                        error = %e,
456                        "run_cli_login: child wait error"
457                    ),
458                }
459            }
460            _ = cancel_rx => {
461                tracing::info!("run_cli_login: cancel signal received, killing child");
462                let _ = child.kill().await;
463            }
464        }
465        // Clear the stored stdin handle once the process is done.
466        *state_for_cleanup.cli_login_stdin.lock() = None;
467    });
468
469    Ok(serde_json::json!({ "auth_url": auth_url }))
470}
471
472/// PTY-backed variant of run_cli_login. Used for providers whose auth
473/// subcommand requires an interactive TTY (currently OpenClaw —
474/// `openclaw models auth login --provider <id>` exits immediately with
475/// "requires an interactive TTY" when stdin is a pipe).
476///
477/// Same return shape as run_cli_login: `{ auth_url: <url or null> }`.
478/// Writes the master writer into `state.cli_login_stdin` so
479/// `set_provider_auth` can deliver an OAuth code if the CLI prompts
480/// for one.
481///
482/// CRITICAL ConPTY lifetime contract on Windows: the PtyPair (master +
483/// slave) MUST stay alive across child.wait(). Same hazard pattern
484/// agentmux-bashwrap navigates. The blocking wait task takes ownership
485/// of the pair so the destructor runs after the child reaps.
486async fn run_cli_login_pty(
487    state: Arc<AppState>,
488    cli_path: String,
489    login_args: Vec<String>,
490    auth_env: std::collections::HashMap<String, String>,
491) -> Result<serde_json::Value, String> {
492    use portable_pty::{native_pty_system, CommandBuilder, PtySize};
493
494    let pty_system = native_pty_system();
495    let pair = pty_system
496        .openpty(PtySize {
497            rows: 24,
498            cols: 80,
499            pixel_width: 0,
500            pixel_height: 0,
501        })
502        .map_err(|e| format!("openpty for {cli_path}: {e}"))?;
503
504    let mut cmd = CommandBuilder::new(&cli_path);
505    for a in &login_args {
506        cmd.arg(a);
507    }
508    for (k, v) in &auth_env {
509        cmd.env(k, v);
510    }
511    if let Ok(cwd) = std::env::current_dir() {
512        cmd.cwd(cwd);
513    }
514
515    let child = pair
516        .slave
517        .spawn_command(cmd)
518        .map_err(|e| format!("PTY spawn of {cli_path}: {e}"))?;
519
520    // Capture the child PID before moving the child into the wait
521    // task — cancel_cli_login needs it to kill the subprocess
522    // platform-side, since aborting the spawn_blocking wait does not
523    // propagate to the child.
524    let child_pid = child.process_id();
525    if let Some(pid) = child_pid {
526        *state.cli_login_pty_pid.lock() = Some(pid);
527    }
528
529    let reader = pair
530        .master
531        .try_clone_reader()
532        .map_err(|e| format!("PTY try_clone_reader: {e}"))?;
533    let writer = pair
534        .master
535        .take_writer()
536        .map_err(|e| format!("PTY take_writer: {e}"))?;
537
538    tracing::info!(cli = %cli_path, pid = ?child_pid, "run_cli_login: spawned (PTY), waiting for OAuth URL");
539
540    // Store the PTY writer so set_provider_auth can deliver an OAuth
541    // code via stdin (some flows prompt the user to paste a code).
542    {
543        let mut stored = state.cli_login_stdin.lock();
544        *stored = Some(CliLoginStdin::Pty(writer));
545    }
546
547    // Synchronously read from the master in a blocking task, scanning
548    // each line for an OAuth URL. portable_pty's reader is sync.
549    // The 15 s cap is enforced async-side via tokio::time::timeout —
550    // BufRead::read_line itself blocks indefinitely without per-read
551    // timeout support, so a child that pauses before its first line
552    // (or sits at a prompt with no newline) would wedge `url_rx.await`
553    // without it. When the timeout fires we return auth_url=None to
554    // the frontend and let the wait task below reap the child whenever
555    // it finishes naturally.
556    let (url_tx, url_rx) = tokio::sync::oneshot::channel::<Option<String>>();
557    tokio::task::spawn_blocking(move || {
558        use std::io::BufRead;
559        let mut reader = std::io::BufReader::new(reader);
560        let mut found: Option<String> = None;
561        let mut line = String::new();
562        loop {
563            line.clear();
564            match reader.read_line(&mut line) {
565                Ok(0) => break, // EOF
566                Ok(_) => {
567                    if let Some(u) = extract_url(&line) {
568                        found = Some(u);
569                        break;
570                    }
571                }
572                Err(e) => {
573                    tracing::warn!(error = %e, "run_cli_login_pty: read error");
574                    break;
575                }
576            }
577        }
578        let _ = url_tx.send(found);
579        // Reader is dropped here. Master keeps living in the wait task
580        // below.
581    });
582
583    let auth_url: Option<String> = match tokio::time::timeout(
584        std::time::Duration::from_secs(15),
585        url_rx,
586    )
587    .await
588    {
589        Ok(Ok(u)) => u,
590        Ok(Err(_)) | Err(_) => None,
591    };
592    if let Some(ref url) = auth_url {
593        tracing::info!(url = %url, "run_cli_login_pty: captured auth URL");
594    } else {
595        tracing::warn!("run_cli_login_pty: no auth URL captured within 15s");
596    }
597
598    // Reap the child in a blocking task. The PtyPair (master + slave)
599    // moves into the closure so its destructor runs AFTER child.wait()
600    // — necessary for ConPTY on Windows (see retro
601    // 2026-05-11-live-log-streaming-wrapper-failures.md §4.2).
602    //
603    // Cancel handling: `cancel_cli_login` reads `cli_login_pty_pid`
604    // and kills the subprocess by PID; once the child dies, this
605    // wait task observes the exit and clears the PID slot.
606    let state_for_cleanup = state.clone();
607    tokio::task::spawn_blocking(move || {
608        let mut child = child;
609        match child.wait() {
610            Ok(status) => tracing::info!(
611                exit_code = ?status.exit_code(),
612                "run_cli_login_pty: child exited"
613            ),
614            Err(e) => tracing::warn!(
615                error = %e,
616                "run_cli_login_pty: child wait error"
617            ),
618        }
619        // pair drops here, after child.wait() returns
620        drop(pair);
621        *state_for_cleanup.cli_login_stdin.lock() = None;
622        *state_for_cleanup.cli_login_pty_pid.lock() = None;
623    });
624
625    Ok(serde_json::json!({ "auth_url": auth_url }))
626}
627
628/// Extract an OAuth URL from a line of CLI output.
629/// Strips ANSI escape sequences and looks for `https://...` substrings.
630fn extract_url(line: &str) -> Option<String> {
631    // Strip ANSI escapes (simple approach: remove ESC[...letter sequences)
632    let clean: String = {
633        let mut out = String::with_capacity(line.len());
634        let bytes = line.as_bytes();
635        let mut i = 0;
636        while i < bytes.len() {
637            if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i+1] == b'[' {
638                // Skip until we find a letter (end of ANSI sequence)
639                i += 2;
640                while i < bytes.len() && !(bytes[i] as char).is_ascii_alphabetic() {
641                    i += 1;
642                }
643                i += 1;
644            } else {
645                out.push(bytes[i] as char);
646                i += 1;
647            }
648        }
649        out
650    };
651
652    // Find https:// and extract until whitespace or end
653    if let Some(start) = clean.find("https://") {
654        let rest = &clean[start..];
655        let end = rest.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
656            .unwrap_or(rest.len());
657        let url = &rest[..end];
658        if url.contains("oauth") || url.contains("auth") || url.contains("login") {
659            return Some(url.to_string());
660        }
661    }
662    None
663}
664
665/// Kill the in-progress CLI login process. Covers both transports:
666/// the pipe path uses a oneshot to drop the Tokio Child (kill_on_drop
667/// terminates the subprocess); the PTY path uses platform-specific
668/// kill-by-PID because the `portable_pty::Child` lives inside a
669/// `spawn_blocking` task that doesn't react to outer-task abort.
670pub fn cancel_cli_login(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
671    // Pipe path.
672    let sender = {
673        let mut stored = state.cli_login_cancel.lock();
674        stored.take()
675    };
676    if let Some(tx) = sender {
677        let _ = tx.send(());
678        tracing::info!("cancel_cli_login: pipe-path cancel signal sent");
679    }
680    // PTY path.
681    let pid = {
682        let mut stored = state.cli_login_pty_pid.lock();
683        stored.take()
684    };
685    if let Some(pid) = pid {
686        if let Err(e) = kill_pid(pid) {
687            tracing::warn!(pid, error = %e, "cancel_cli_login: kill_pid failed");
688        } else {
689            tracing::info!(pid, "cancel_cli_login: PTY child killed");
690        }
691    }
692    Ok(serde_json::Value::Null)
693}
694
695/// Platform-specific best-effort kill of a child process by PID.
696#[cfg(windows)]
697fn kill_pid(pid: u32) -> std::io::Result<()> {
698    // Use taskkill /F /T so the whole tree dies — `openclaw models
699    // auth login` typically spawns a child that opens the browser.
700    let status = std::process::Command::new("taskkill")
701        .args(["/F", "/T", "/PID", &pid.to_string()])
702        .stdin(std::process::Stdio::null())
703        .stdout(std::process::Stdio::null())
704        .stderr(std::process::Stdio::null())
705        .status()?;
706    if status.success() {
707        Ok(())
708    } else {
709        Err(std::io::Error::other(format!("taskkill exit {:?}", status.code())))
710    }
711}
712
713#[cfg(unix)]
714fn kill_pid(pid: u32) -> std::io::Result<()> {
715    // SIGTERM first; an aborting subprocess gets a chance to clean up.
716    let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
717    if ret != 0 {
718        return Err(std::io::Error::last_os_error());
719    }
720    Ok(())
721}
722
723// --- CLI command helpers ---
724
725fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
726    agentmux_common::make_cli_cmd(cli_path)
727}
728
729// --- Settings helpers (ported from src-tauri/src/commands/platform.rs) ---
730
731fn read_settings_jsonc(path: &std::path::Path) -> serde_json::Map<String, serde_json::Value> {
732    if !path.exists() {
733        return serde_json::Map::new();
734    }
735    match std::fs::read_to_string(path) {
736        Ok(content) => {
737            let stripped = json_comments::StripComments::new(content.as_bytes());
738            let mut json_bytes = Vec::new();
739            std::io::BufReader::new(stripped)
740                .read_to_end(&mut json_bytes)
741                .unwrap_or_default();
742            let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
743            match serde_json::from_str::<serde_json::Value>(&json_str) {
744                Ok(serde_json::Value::Object(map)) => map,
745                _ => serde_json::Map::new(),
746            }
747        }
748        Err(_) => serde_json::Map::new(),
749    }
750}
751
752fn strip_trailing_commas(input: &str) -> String {
753    let mut result = String::with_capacity(input.len());
754    let mut in_string = false;
755    let mut last_comma_pos: Option<usize> = None;
756
757    for ch in input.chars() {
758        if in_string {
759            result.push(ch);
760            if ch == '"' {
761                let backslashes = result[..result.len() - 1]
762                    .chars()
763                    .rev()
764                    .take_while(|&c| c == '\\')
765                    .count();
766                if backslashes % 2 == 0 {
767                    in_string = false;
768                }
769            }
770            continue;
771        }
772        match ch {
773            '"' => {
774                in_string = true;
775                last_comma_pos = None;
776                result.push(ch);
777            }
778            ',' => {
779                last_comma_pos = Some(result.len());
780                result.push(ch);
781            }
782            '}' | ']' => {
783                if let Some(pos) = last_comma_pos {
784                    result.replace_range(pos..pos + 1, " ");
785                }
786                last_comma_pos = None;
787                result.push(ch);
788            }
789            _ if ch.is_whitespace() => {
790                result.push(ch);
791            }
792            _ => {
793                last_comma_pos = None;
794                result.push(ch);
795            }
796        }
797    }
798    result
799}
800
801fn merge_into_template(
802    template: &str,
803    user_settings: &serde_json::Map<String, serde_json::Value>,
804) -> String {
805    if user_settings.is_empty() {
806        return template.to_string();
807    }
808
809    let mut remaining: std::collections::HashMap<&str, &serde_json::Value> =
810        user_settings.iter().map(|(k, v)| (k.as_str(), v)).collect();
811    let mut lines: Vec<String> = Vec::new();
812
813    for line in template.lines() {
814        if let Some(key) = extract_commented_setting_key(line) {
815            if let Some(value) = remaining.remove(key) {
816                let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
817                let val_str = serde_json::to_string(value).unwrap_or_default();
818                lines.push(format!("{}\"{}\": {},", indent, key, val_str));
819                continue;
820            }
821        }
822        lines.push(line.to_string());
823    }
824
825    if !remaining.is_empty() {
826        if let Some(brace_pos) = lines.iter().rposition(|l| l.trim() == "}") {
827            let mut extra: Vec<String> = Vec::new();
828            extra.push(String::new());
829            extra.push("    // -- User Overrides --".to_string());
830            let mut sorted_keys: Vec<&&str> = remaining.keys().collect();
831            sorted_keys.sort();
832            for key in sorted_keys {
833                let value = remaining[*key];
834                let val_str = serde_json::to_string(value).unwrap_or_default();
835                extra.push(format!("    \"{}\": {},", key, val_str));
836            }
837            for (i, line) in extra.into_iter().enumerate() {
838                lines.insert(brace_pos + i, line);
839            }
840        }
841    }
842
843    let mut result = lines.join("\n");
844    if !result.ends_with('\n') {
845        result.push('\n');
846    }
847    result
848}
849
850/// Open a URL in the system's default browser.
851pub fn open_external(args: &serde_json::Value) -> Result<serde_json::Value, String> {
852    let url = args
853        .get("url")
854        .and_then(|v| v.as_str())
855        .ok_or_else(|| "Missing url".to_string())?;
856
857    // Only allow safe URL schemes
858    if !url.starts_with("http://") && !url.starts_with("https://") && !url.starts_with("devtools://") {
859        return Err(format!("Refusing to open URL with unsupported scheme: {}", url));
860    }
861
862    #[cfg(target_os = "windows")]
863    {
864        // Use rundll32 url.dll,FileProtocolHandler instead of explorer.exe or
865        // cmd /C start. Explorer is a file manager — when it is already running
866        // (always the case on Windows), passing a URL to a second explorer
867        // instance is unreliable and sometimes opens a file-manager window.
868        // cmd.exe interprets & and | in URLs as command separators (injection).
869        // url.dll,FileProtocolHandler is the Windows built-in URL dispatcher:
870        // it reads HKCR\https\shell\open\command and always opens the default
871        // browser, handling any printable characters in the URL safely.
872        let _ = std::process::Command::new("rundll32.exe")
873            .args(["url.dll,FileProtocolHandler", url])
874            .spawn()
875            .map_err(|e| format!("Failed to open URL: {}", e))?;
876    }
877    #[cfg(target_os = "macos")]
878    {
879        let _ = std::process::Command::new("open")
880            .arg(url)
881            .spawn()
882            .map_err(|e| format!("Failed to open URL: {}", e))?;
883    }
884    #[cfg(target_os = "linux")]
885    {
886        let _ = std::process::Command::new("xdg-open")
887            .arg(url)
888            .spawn()
889            .map_err(|e| format!("Failed to open URL: {}", e))?;
890    }
891
892    Ok(serde_json::Value::Null)
893}
894
895fn extract_commented_setting_key(line: &str) -> Option<&str> {
896    let trimmed = line.trim_start();
897    let rest = trimmed.strip_prefix("//")?;
898    let rest = rest.trim_start();
899    let rest = rest.strip_prefix('"')?;
900    let end = rest.find('"')?;
901    Some(&rest[..end])
902}