fix(desktop): windows console, spawn, and url-opening hygiene
Four Windows runtime defects from the platform audit: - The binary stayed in the console subsystem, parking a console window behind the GUI when launched from Explorer. Release builds now set windows_subsystem = "windows"; debug keeps stderr tracing visible. - Background CLI probes (model discovery, provider version checks) and the vendored Claude SDK's per-turn spawns lacked CREATE_NO_WINDOW, flashing console windows once the GUI detaches from the console. - MCP stdio servers naming .cmd/.bat shims (npx and most npm-installed servers) could not spawn: CreateProcess cannot execute shims and Rust 1.77+ refuses them as program names. vendor/agent now resolves the command PATHEXT-style against the PATH the server will actually see (per-server env override wins) and routes only genuine shims through cmd /c — real executables keep direct spawn semantics. - cmd /C start truncated URLs at `&` (every OAuth authorize URL). The URL now travels double-quoted via raw_arg so cmd keeps it literal.
This commit is contained in:
parent
bde00122d5
commit
47ff290cf6
|
|
@ -228,10 +228,14 @@ fn open_in_browser(url: &str) {
|
|||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new("cmd");
|
||||
// The empty "" is `start`'s window-title slot — without it a quoted
|
||||
// URL would be consumed as the title.
|
||||
c.args(["/C", "start", "", url]);
|
||||
// Raw arg keeps the URL inside double quotes so cmd doesn't
|
||||
// split it at `&`; the empty "" is `start`'s window-title slot —
|
||||
// without it the quoted URL would be consumed as the title.
|
||||
c.raw_arg(windows_start_args(url));
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
};
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
|
|
@ -243,6 +247,14 @@ fn open_in_browser(url: &str) {
|
|||
let _ = spawn_null(&mut command);
|
||||
}
|
||||
|
||||
/// Args for `cmd /C start "" "<url>"` with the URL double-quoted so cmd
|
||||
/// doesn't split it at `&`. `"` is illegal in URLs — stripped
|
||||
/// defensively.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn windows_start_args(url: &str) -> String {
|
||||
format!("/C start \"\" \"{}\"", url.replace('"', "%22"))
|
||||
}
|
||||
|
||||
/// `op start --web` success JSON: the headless `start_json` shape plus
|
||||
/// `mode:"web"` (and the non-default bind host when one was requested, so
|
||||
/// scripts can derive the LAN URL).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
//! Owns the event loop, GL surface, DPI, animation timer + cursor input.
|
||||
|
||||
#![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
// Detach from the console subsystem in release builds so launching from
|
||||
// Explorer / the Start menu doesn't park a console window behind the GUI.
|
||||
// Debug builds keep the console — tracing writes to stderr (init_tracing).
|
||||
#![cfg_attr(
|
||||
all(target_os = "windows", not(debug_assertions)),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
mod a11y;
|
||||
mod acp_agent_probe_host;
|
||||
|
|
|
|||
|
|
@ -245,15 +245,35 @@ fn download_and_open_blocking(version: &str) -> bool {
|
|||
open_installer_path(&dest)
|
||||
}
|
||||
|
||||
/// Args for `cmd /C start "" "<target>"` with the target double-quoted
|
||||
/// so cmd doesn't split it at `&` (OAuth / query-string URLs) or at
|
||||
/// spaces (installer paths). Inside double quotes cmd keeps its
|
||||
/// metacharacters literal; `"` itself is illegal in URLs and Windows
|
||||
/// paths but is stripped defensively. `%VAR%` expansion remains
|
||||
/// possible in pathological inputs — accepted, matches the `open`
|
||||
/// crate's behaviour.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn windows_start_args(target: &str) -> String {
|
||||
format!("/C start \"\" \"{}\"", target.replace('"', "%22"))
|
||||
}
|
||||
|
||||
/// Spawn `cmd /C start "" "<target>"` without flashing a console.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_start(target: &str) -> std::io::Result<std::process::Child> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.raw_arg(windows_start_args(target));
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
c.spawn()
|
||||
}
|
||||
|
||||
/// Open the downloaded installer with the platform launcher.
|
||||
fn open_installer_path(path: &std::path::Path) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = std::process::Command::new("open").arg(path).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", ""])
|
||||
.arg(path)
|
||||
.spawn();
|
||||
let result = windows_start(&path.display().to_string());
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = std::process::Command::new("xdg-open").arg(path).spawn();
|
||||
result.is_ok()
|
||||
|
|
@ -294,9 +314,7 @@ pub fn open_url(url: &str) {
|
|||
#[cfg(target_os = "linux")]
|
||||
let result = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn();
|
||||
let result = windows_start(url);
|
||||
if let Err(e) = result {
|
||||
eprintln!("openpencil-desktop: open_url({url}) failed: {e}");
|
||||
}
|
||||
|
|
@ -306,6 +324,16 @@ pub fn open_url(url: &str) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_start_args_quotes_urls_with_query_params() {
|
||||
assert_eq!(
|
||||
windows_start_args("https://a.example/auth?b=1&c=2"),
|
||||
"/C start \"\" \"https://a.example/auth?b=1&c=2\""
|
||||
);
|
||||
// Double quotes are illegal in URLs/paths — stripped defensively.
|
||||
assert_eq!(windows_start_args("x\"y"), "/C start \"\" \"x%22y\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_compares_dotted_numbers() {
|
||||
assert!(is_newer("0.9.0", "0.8.0"));
|
||||
|
|
|
|||
|
|
@ -919,13 +919,27 @@ fn open_external_url(url: &str) {
|
|||
#[cfg(target_os = "macos")]
|
||||
let _ = std::process::Command::new("open").arg(url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn();
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.raw_arg(windows_start_args(url));
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
let _ = c.spawn();
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
/// Args for `cmd /C start "" "<url>"` with the URL double-quoted so
|
||||
/// cmd doesn't split it at `&` (query-string URLs like the Openverse
|
||||
/// OAuth registration link). `"` is illegal in URLs — stripped
|
||||
/// defensively.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn windows_start_args(url: &str) -> String {
|
||||
format!("/C start \"\" \"{}\"", url.replace('"', "%22"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::create_initial_size_for_tool;
|
||||
|
|
|
|||
|
|
@ -170,6 +170,26 @@ pub fn build_command(binary: &str, args: &[String]) -> Command {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply CREATE_NO_WINDOW to a blocking `std::process::Command` so
|
||||
/// background CLI probes (model discovery, provider version checks)
|
||||
/// don't flash console windows once the desktop binary runs detached
|
||||
/// from the console subsystem (`windows_subsystem = "windows"`).
|
||||
/// No-op off Windows.
|
||||
pub(crate) fn hide_console_window(cmd: &mut std::process::Command) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW from winbase.h — same flag build_command
|
||||
// applies to the streaming tokio commands.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = cmd;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stringify an `ExitStatus` for chat error reporting. Cross-platform:
|
||||
/// on Unix `.code()` is `None` when killed by signal — show the signal
|
||||
/// number instead; on Windows `.code()` is always populated.
|
||||
|
|
|
|||
|
|
@ -278,13 +278,13 @@ fn discover_codex() -> Vec<ModelEntry> {
|
|||
/// falls back to the on-disk cache.
|
||||
pub fn codex_models_from_app_server() -> Option<Vec<ModelEntry>> {
|
||||
let exe = resolve_cli("codex")?;
|
||||
let mut child = Command::new(exe)
|
||||
.arg("app-server")
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("app-server")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
.stderr(Stdio::null());
|
||||
crate::chat_spawn::hide_console_window(&mut cmd);
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
let mut stdin = child.stdin.take()?;
|
||||
let stdout = child.stdout.take()?;
|
||||
|
||||
|
|
@ -440,13 +440,13 @@ fn discover_copilot() -> Vec<ModelEntry> {
|
|||
/// Rust 1.94). Returns `None` on any failure.
|
||||
fn copilot_models_from_stdio() -> Option<Vec<ModelEntry>> {
|
||||
let exe = resolve_cli("copilot")?;
|
||||
let mut child = Command::new(exe)
|
||||
.arg("--stdio")
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("--stdio")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
.stderr(Stdio::null());
|
||||
crate::chat_spawn::hide_console_window(&mut cmd);
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
let mut stdin = child.stdin.take()?;
|
||||
let stdout = child.stdout.take()?;
|
||||
|
||||
|
|
@ -574,7 +574,10 @@ pub fn discover_opencode() -> Vec<ModelEntry> {
|
|||
let Some(exe) = resolve_cli("opencode") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(output) = Command::new(exe).arg("models").output() else {
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("models");
|
||||
crate::chat_spawn::hide_console_window(&mut cmd);
|
||||
let Ok(output) = cmd.output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
|
|
|
|||
|
|
@ -183,13 +183,13 @@ fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
|
|||
/// with `2>&1`). `None` on spawn failure, non-zero exit, or
|
||||
/// timeout — the "CLI not responding" path.
|
||||
fn cli_version(exe: &Path, timeout: Duration) -> Option<String> {
|
||||
let mut child = Command::new(exe)
|
||||
.arg("--version")
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
.stderr(Stdio::piped());
|
||||
crate::chat_spawn::hide_console_window(&mut cmd);
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
|
|
@ -578,13 +578,13 @@ fn copilot_connection_info(auth: Option<&CopilotAuth>) -> String {
|
|||
/// when the model list never answered; auth is best-effort (TS
|
||||
/// logs and continues when `getAuthStatus` fails).
|
||||
fn copilot_probe_stdio(exe: &Path) -> Option<(Vec<ModelEntry>, Option<CopilotAuth>)> {
|
||||
let mut child = Command::new(exe)
|
||||
.arg("--stdio")
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("--stdio")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
.stderr(Stdio::null());
|
||||
crate::chat_spawn::hide_console_window(&mut cmd);
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
let mut stdin = child.stdin.take()?;
|
||||
let stdout = child.stdout.take()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ pub fn claude_initialize_query() -> ClaudeInitResult {
|
|||
cmd.env("CLAUDE_CODE_ENTRYPOINT", "sdk-ts");
|
||||
}
|
||||
cmd.env_remove("NODE_OPTIONS");
|
||||
crate::chat_spawn::hide_console_window(&mut cmd);
|
||||
let Ok(mut child) = cmd.spawn() else {
|
||||
return ClaudeInitResult::NoAnswer;
|
||||
};
|
||||
|
|
|
|||
2
vendor/agent
vendored
2
vendor/agent
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit c0804d9061323bd86c9efa68f177d6006875b7a5
|
||||
Subproject commit a6f1897c3a23b59b5539c9bc1a6b28f6d5da2a72
|
||||
12
vendor/anthropic-agent-sdk/src/auth/oauth.rs
vendored
12
vendor/anthropic-agent-sdk/src/auth/oauth.rs
vendored
|
|
@ -522,9 +522,15 @@ impl OAuthClient {
|
|||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
// Double-quote the URL so cmd doesn't split it at `&` — the
|
||||
// OAuth authorize URL always carries multiple query params.
|
||||
// `"` is illegal in URLs; strip it defensively.
|
||||
c.raw_arg(format!("/C start \"\" \"{}\"", url.replace('"', "%22")));
|
||||
c.creation_flags(CREATE_NO_WINDOW);
|
||||
c.spawn()
|
||||
.map_err(|e| OAuthError::BrowserOpen(e.to_string()))?;
|
||||
}
|
||||
|
||||
|
|
|
|||
11
vendor/anthropic-agent-sdk/src/transport/mod.rs
vendored
11
vendor/anthropic-agent-sdk/src/transport/mod.rs
vendored
|
|
@ -66,8 +66,15 @@ pub trait Transport: Send + Sync {
|
|||
pub async fn check_claude_version(cli_path: &std::path::Path) -> crate::Result<String> {
|
||||
use tokio::process::Command;
|
||||
|
||||
let output = Command::new(cli_path)
|
||||
.arg("--version")
|
||||
let mut cmd = Command::new(cli_path);
|
||||
cmd.arg("--version");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// CREATE_NO_WINDOW — version probes run behind the GUI; don't
|
||||
// flash a console window per probe.
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| crate::ClaudeError::connection(format!("Failed to get CLI version: {e}")))?;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,14 @@ impl SubprocessTransport {
|
|||
fn build_command(&self) -> Result<Command> {
|
||||
let mut cmd = Command::new(&self.cli_path);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// CREATE_NO_WINDOW — every routed chat turn spawns the CLI;
|
||||
// without this each spawn flashes a console window when the
|
||||
// host GUI runs detached from the console subsystem.
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
// Always use --print for non-interactive mode to avoid terminal manipulation
|
||||
cmd.arg("--print");
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue