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:
Kayshen-X 2026-07-03 00:08:21 +08:00
parent bde00122d5
commit 47ff290cf6
12 changed files with 146 additions and 40 deletions

View file

@ -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).

View file

@ -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;

View file

@ -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"));

View file

@ -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;

View file

@ -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.

View file

@ -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() {

View file

@ -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()?;

View file

@ -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

@ -1 +1 @@
Subproject commit c0804d9061323bd86c9efa68f177d6006875b7a5
Subproject commit a6f1897c3a23b59b5539c9bc1a6b28f6d5da2a72

View file

@ -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()))?;
}

View file

@ -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}")))?;

View file

@ -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");