diff --git a/assets/locales/ar.json b/assets/locales/ar.json index 2fc1f4b52b..2bb7341763 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.json @@ -4248,6 +4248,7 @@ "ui.agent_chat_no_images": "هذا الوكيل لا يقبل الصور", "ui.agent_chat_no_picture": "لا توجد صورة في الحافظة", "ui.agent_chat_no_tool_call": "لم يستخدم النموذج أدوات SolidCraft، لذا لم يتغيّر التصميم. اختر نموذجًا يدعم استدعاء الأدوات؛ راجع «النماذج المحلية» في دليل محادثة الوكيل.", + "ui.agent_chat_node_too_old": "إصدار Node.js ‏{version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط على تحديث.", "ui.agent_chat_none_found": "لم يُعثر على وكيل برمجي. ثبّت أحد هذه ثم اضغط تحديث:", "ui.agent_chat_none_found_short": "لم يُعثر على وكيل", "ui.agent_chat_not_installed": "غير مثبت", diff --git a/assets/locales/de.json b/assets/locales/de.json index b9e3c318ac..41508742df 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -4231,6 +4231,7 @@ "ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an", "ui.agent_chat_no_picture": "In der Zwischenablage befindet sich kein Bild", "ui.agent_chat_no_tool_call": "Das Modell hat die Werkzeuge von SolidCraft nicht verwendet, daher wurde das Design nicht geändert. Wählen Sie ein Modell, das Werkzeugaufrufe unterstützt; siehe „Lokale Modelle“ im Leitfaden zum Agenten-Chat.", + "ui.agent_chat_node_too_old": "Node.js {version} ist für diesen Agenten zu alt. Installieren Sie Node.js 20.10 oder neuer und klicken Sie dann auf Aktualisieren.", "ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:", "ui.agent_chat_none_found_short": "Kein Agent gefunden", "ui.agent_chat_not_installed": "Nicht installiert", diff --git a/assets/locales/en.json b/assets/locales/en.json index 141c102fb3..89ff50dfe5 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -4248,6 +4248,7 @@ "ui.agent_chat_no_images": "This agent doesn't take pictures", "ui.agent_chat_no_picture": "There is no picture on the clipboard", "ui.agent_chat_no_tool_call": "The model didn't use SolidCraft's tools, so the design wasn't changed. Choose a model that supports tool calling; see Local models in the agent chat guide.", + "ui.agent_chat_node_too_old": "Node.js {version} is too old for this agent. Install Node.js 20.10 or newer, then press Refresh.", "ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:", "ui.agent_chat_none_found_short": "No agent found", "ui.agent_chat_not_installed": "Not installed", diff --git a/assets/locales/he.json b/assets/locales/he.json index f0fa7cb159..1a99576349 100644 --- a/assets/locales/he.json +++ b/assets/locales/he.json @@ -4248,6 +4248,7 @@ "ui.agent_chat_no_images": "הסוכן הזה לא מקבל תמונות", "ui.agent_chat_no_picture": "אין תמונה בלוח", "ui.agent_chat_no_tool_call": "המודל לא השתמש בכלים של SolidCraft, ולכן התכן לא השתנה. יש לבחור מודל שתומך בקריאה לכלים; ראו „מודלים מקומיים” במדריך צ'אט הסוכן.", + "ui.agent_chat_node_too_old": "גרסת Node.js ‏{version} ישנה מדי עבור סוכן זה. יש להתקין Node.js 20.10 ומעלה, ואז ללחוץ על רענון.", "ui.agent_chat_none_found": "לא נמצא סוכן קוד. יש להתקין אחד מאלה ואז ללחוץ על רענון:", "ui.agent_chat_none_found_short": "לא נמצא סוכן", "ui.agent_chat_not_installed": "לא מותקן", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 16db170d65..9b4edacbff 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -4248,6 +4248,7 @@ "ui.agent_chat_no_images": "このエージェントは画像を受け付けません", "ui.agent_chat_no_picture": "クリップボードに画像がありません", "ui.agent_chat_no_tool_call": "モデルが SolidCraft のツールを使わなかったため、デザインは変更されませんでした。ツール呼び出しに対応したモデルを選んでください。エージェントチャットのガイドの「ローカルモデル」を参照してください。", + "ui.agent_chat_node_too_old": "Node.js {version} はこのエージェントには古すぎます。Node.js 20.10 以降をインストールしてから、更新を押してください。", "ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:", "ui.agent_chat_none_found_short": "エージェントが見つかりません", "ui.agent_chat_not_installed": "未インストール", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 3143d29329..4ee4dae727 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -3059,6 +3059,7 @@ "ui.agent_chat_no_control": "에이전트 채팅 엔드포인트가 시작되지 않아 에이전트가 디자인에 접근할 수 없습니다.", "ui.agent_chat_no_images": "이 에이전트는 이미지를 받지 않습니다", "ui.agent_chat_no_picture": "클립보드에 이미지가 없습니다", + "ui.agent_chat_node_too_old": "Node.js {version}은 이 에이전트에 사용하기에는 너무 오래된 버전입니다. Node.js 20.10 이상을 설치한 후 새로 고침을 누르세요.", "ui.agent_chat_none_found": "코딩 에이전트를 찾지 못했습니다. 다음 중 하나를 설치한 뒤 새로 고침을 누르세요:", "ui.agent_chat_none_found_short": "에이전트를 찾지 못함", "ui.agent_chat_not_installed": "설치되지 않음", diff --git a/assets/locales/vi.json b/assets/locales/vi.json index 39d4f93247..7f55f8c56f 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -4248,6 +4248,7 @@ "ui.agent_chat_no_images": "AI Agent này không hỗ trợ tiếp nhận hình ảnh", "ui.agent_chat_no_picture": "Không có hình ảnh trong bộ nhớ tạm", "ui.agent_chat_no_tool_call": "Mô hình không sử dụng các công cụ của SolidCraft nên thiết kế không thay đổi. Hãy chọn mô hình hỗ trợ gọi công cụ; xem mục Mô hình cục bộ trong hướng dẫn trò chuyện AI.", + "ui.agent_chat_node_too_old": "Node.js {version} quá cũ đối với tác nhân này. Hãy cài đặt Node.js 20.10 trở lên, rồi nhấn Làm mới.", "ui.agent_chat_none_found": "Không tìm thấy tác tử lập trình nào. Hãy cài đặt một trong các tác tử sau, rồi nhấn Làm mới:", "ui.agent_chat_none_found_short": "Không tìm thấy tác tử nào", "ui.agent_chat_not_installed": "Chưa cài đặt", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 0bd1eed6fb..ad13c9e0d9 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -4248,6 +4248,7 @@ "ui.agent_chat_no_images": "该智能体不接收图片", "ui.agent_chat_no_picture": "剪贴板上没有图片", "ui.agent_chat_no_tool_call": "模型没有使用 SolidCraft 的工具,因此设计没有更改。请选择支持工具调用的模型;参见智能体聊天指南中的“本地模型”。", + "ui.agent_chat_node_too_old": "Node.js {version} 版本过旧,无法运行此代理。请安装 Node.js 20.10 或更新版本,然后点击刷新。", "ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:", "ui.agent_chat_none_found_short": "未找到智能体", "ui.agent_chat_not_installed": "未安装", diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs index 24792f42c5..cec01600eb 100644 --- a/crates/ui-egui/src/agent_chat/discover.rs +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -275,7 +275,9 @@ pub fn runnable_sibling(program: &Path) -> Option { /// Start probes and sessions with the same PATH used for discovery. Desktop launchers often /// omit Node's install folder even when we found an adapter with a `#!/usr/bin/env node` shebang. -/// Only the child's environment changes; inherited absolute PATH entries keep precedence. +/// Only the child's environment changes. An explicitly selected Node also takes precedence for +/// its descendants; otherwise inherited absolute PATH entries keep precedence. No probes here: +/// starting a session calls this on the UI thread. pub(super) fn command_for(program: &Path) -> Command { // Older saved Windows commands may name npm's bare Unix script rather than its .cmd shim. let shim = if cfg!(windows) { runnable_sibling(program) } else { None }; @@ -287,7 +289,15 @@ pub(super) fn command_for(program: &Path) -> Command { } else { Command::new(program) }; - if let Ok(path) = std::env::join_paths(search_dirs()) { + let mut dirs = search_dirs(); + if program.is_absolute() + && program.file_name().is_some_and(|n| n == "node" || n == "node.exe") + && let Some(parent) = program.parent() + { + dirs.retain(|d| d != parent); + dirs.insert(0, parent.to_path_buf()); + } + if let Ok(path) = std::env::join_paths(dirs) { cmd.env("PATH", path); } cmd @@ -301,6 +311,8 @@ struct Probe { /// Why it can't start: the system refused to start it, or it failed and printed nothing but /// an error. A probe that runs out of time says nothing either way (a slow machine). problem: Option, + /// A failed JSON import-attributes parse on a Node version that cannot support it. + old_node: Option, } /// How long an agent is given to answer `--version`. @@ -311,7 +323,10 @@ const PROBLEM_CHARS: usize = 300; /// Ask `program --version`; it is stopped after `limit`. fn probe(program: &Path, limit: Duration) -> Probe { - let mut cmd = command_for(program); + probe_command(program, command_for(program), limit) +} + +fn probe_command(program: &Path, mut cmd: Command, limit: Duration) -> Probe { #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -323,7 +338,7 @@ fn probe(program: &Path, limit: Duration) -> Probe { // Windows: "not a valid Win32 application" (193), "not compatible with the version of // Windows you're running" (216); elsewhere: not allowed to run it, an unknown format. // Windows leaves the program's place in its message as `%1`. - Err(e) => return Probe { version: None, problem: Some(cut(&e.to_string().replace("%1", &program.display().to_string()))) }, + Err(e) => return Probe { problem: Some(cut(&e.to_string().replace("%1", &program.display().to_string()))), ..Probe::default() }, }; let start = Instant::now(); let status = loop { @@ -339,7 +354,7 @@ fn probe(program: &Path, limit: Duration) -> Probe { }; let out = read_capped(child.stdout.take()); if let Some(line) = out.lines().map(str::trim).find(|l| !l.is_empty()) { - return Probe { version: Some(version_of(line)), problem: None }; + return Probe { version: Some(version_of(line)), ..Probe::default() }; } if status.success() { return Probe::default(); @@ -347,7 +362,54 @@ fn probe(program: &Path, limit: Duration) -> Probe { // Through a `.cmd` shim, Windows' own refusal arrives on stderr with a failing exit code. let err = read_capped(child.stderr.take()); let reason = err.lines().map(str::trim).find(|l| !l.is_empty()).map(str::to_string).unwrap_or_else(|| status.to_string()); - Probe { version: None, problem: Some(cut(&reason)) } + let old_node = err + .contains("SyntaxError: Unexpected token 'with'") + .then(|| { + err.lines().find_map(|line| { + let version = line.trim().strip_prefix("Node.js ")?; + (node_import_attributes(version) == Some(false)).then(|| version.to_string()) + }) + }) + .flatten(); + Probe { version: None, problem: Some(cut(&reason)), old_node } +} + +/// Node added import attributes in 18.20, 20.10 and 21.0. Reject unrecognised versions. +fn node_import_attributes(version: &str) -> Option { + let mut parts = version.strip_prefix('v')?.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let _patch = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + Some(major >= 21 || (major == 20 && minor >= 10) || (major == 18 && minor >= 20)) +} + +/// Only replace an unambiguous env-node shebang, never a shell shim or a pinned interpreter. +fn uses_env_node(program: &Path) -> bool { + use std::io::Read; + let Ok(file) = std::fs::File::open(program) else { return false }; + let mut header = String::new(); + if file.take(256).read_to_string(&mut header).is_err() { + return false; + } + let words: Vec<_> = header.lines().next().unwrap_or_default().split_whitespace().collect(); + matches!(words.as_slice(), ["#!/usr/bin/env" | "#!/bin/env", "node"] | ["#!/usr/bin/env" | "#!/bin/env", "-S", "node"]) +} + +/// These checks run only after the adapter fails on an old Node, on the discovery worker. +/// All candidate probes share one time budget, so many installed versions cannot stall Refresh. +fn compatible_node(dirs: &[PathBuf], limit: Duration) -> Option { + let start = Instant::now(); + for dir in dirs.iter().take(64) { + let Some(node) = find_in("node", std::slice::from_ref(dir)) else { continue }; + let remaining = limit.checked_sub(start.elapsed()).filter(|d| !d.is_zero())?; + if probe(&node, remaining).version.as_deref().and_then(node_import_attributes) == Some(true) { + return Some(node); + } + } + None } /// What a finished probe wrote on a pipe, up to 64 KiB. @@ -406,7 +468,20 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>, limit: Duration) -> Detectio let login = login_of(k, home); let mut argv = vec![path.to_string_lossy().into_owned()]; argv.extend(k.args.iter().map(|a| a.to_string())); - let Probe { version, problem } = if k.reports_version { probe(&path, limit) } else { Probe::default() }; + let mut result = if k.reports_version { probe(&path, limit) } else { Probe::default() }; + if let Some(version) = result.old_node.as_deref() { + result.problem = Some(crate::i18n::format("ui.agent_chat_node_too_old", &[("version", version)])); + if uses_env_node(&path) + && let Some(node) = compatible_node(dirs, limit) + { + let mut cmd = command_for(&node); + cmd.arg(&path); + result = probe_command(&node, cmd, limit); + // Keep this interpreter for the live session as well as the version check. + argv.insert(0, node.to_string_lossy().into_owned()); + } + } + let Probe { version, problem, .. } = result; d.found.push(Found { id: k.id, name: k.name, version, argv, login, problem, install: k.install }); } d.took_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX); @@ -468,6 +543,111 @@ pub(super) fn tools_capability(value: &serde_json::Value) -> Option { mod tests { use super::*; + /// A desktop PATH can name an old Node before the runtime installed with an npm adapter. + /// Both the version probe and the live session must use the recovered interpreter. + #[test] + #[cfg(unix)] + fn a_node_adapter_recovers_from_an_incompatible_desktop_runtime() { + use super::super::process::{AgentProcess, FromAgent}; + use std::os::unix::fs::PermissionsExt; + + const CHILD: &str = "SOLIDCRAFT_NODE_COMPAT_TEST"; + const BROKEN_NODE: &str = "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v18.19.1; else echo \"SyntaxError: Unexpected token 'with'\" >&2; echo 'Node.js v18.19.1' >&2; exit 1; fi\n"; + if let Some(root) = std::env::var_os(CHILD) { + let root = PathBuf::from(root); + let old = root.join("old"); + let newer = root.join("modern/bin"); + let dirs = [old.clone(), newer.clone()]; + let find = || detect_in(&dirs, None, Duration::from_secs(20)).found.into_iter().find(|f| f.id == "claude").unwrap(); + let inherited_path = std::env::var_os("PATH"); + let found = find(); + assert_eq!(found.version.as_deref(), Some("1.2.3"), "{found:?}"); + assert_eq!(found.problem, None); + assert_eq!(found.argv.first(), Some(&newer.join("node").to_string_lossy().into_owned())); + assert_eq!(found.argv.get(1), Some(&old.join("claude-agent-acp").to_string_lossy().into_owned())); + assert_eq!(std::env::var_os("PATH"), inherited_path, "only the child's PATH may change"); + + let mut process = AgentProcess::spawn(&found.argv, &root, &[], || {}).unwrap(); + let message = serde_json::json!({"id": 1, "method": "initialize"}); + process.send(&message); + let reply = process.from_agent.recv_timeout(Duration::from_secs(20)).unwrap(); + process.shutdown(true); + match reply { + FromAgent::Line(line) => assert_eq!(serde_json::from_str::(&line).unwrap(), message), + FromAgent::Exited(tail) => panic!("the recovered adapter did not start: {tail:?}"), + } + + // An older runtime that already runs the adapter is left alone. + std::fs::write(old.join("node"), "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v18.19.1; else echo adapter 9.9.9; fi\n").unwrap(); + let working = find(); + assert_eq!(working.version.as_deref(), Some("9.9.9")); + assert_eq!(working.argv.first(), Some(&old.join("claude-agent-acp").to_string_lossy().into_owned())); + + // Without a compatible runtime the reason must name Node and the remedy. + std::fs::write(old.join("node"), BROKEN_NODE).unwrap(); + std::fs::remove_file(newer.join("node")).unwrap(); + let unavailable = find(); + assert_eq!(unavailable.version, None); + let reason = unavailable.problem.unwrap(); + assert!(reason.contains("18.19.1") && reason.contains("20.10") && reason.contains("Refresh"), "{reason}"); + return; + } + + let root = std::env::temp_dir().join(format!("sc agent node compat-{}", std::process::id())); + let old = root.join("old"); + let newer = root.join("modern/bin"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::create_dir_all(&newer).unwrap(); + for (path, script) in [ + (old.join("claude-agent-acp"), "#!/usr/bin/env node\n"), + (old.join("node"), BROKEN_NODE), + ( + newer.join("node"), + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v22.0.0; elif [ \"$2\" = \"--version\" ]; then echo adapter 1.2.3; elif [ \"$(node --version)\" = v22.0.0 ]; then /bin/cat; else exit 1; fi\n", + ), + ] { + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "agent_chat::discover::tests::a_node_adapter_recovers_from_an_incompatible_desktop_runtime", "--nocapture"]) + .env(CHILD, &root) + .env("PATH", std::env::join_paths([old, PathBuf::from("/usr/bin"), PathBuf::from("/bin")]).unwrap()) + .env("NPM_CONFIG_PREFIX", root.join("modern")) + .output() + .unwrap(); + let _ = std::fs::remove_dir_all(root); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + } + + #[test] + fn node_import_attributes_require_a_supported_release() { + for version in ["v18.20.0", "v18.20.8", "v20.10.0", "v20.19.0", "v21.0.0", "v22.0.0"] { + assert_eq!(node_import_attributes(version), Some(true), "{version}"); + } + for version in ["v16.20.2", "v18.19.1", "v19.9.0", "v20.9.0"] { + assert_eq!(node_import_attributes(version), Some(false), "{version}"); + } + for version in ["", "node", "v20", "v20.10", "v22.0.0-rc.1", "v22.0.0.1", "v18446744073709551616.0.0"] { + assert_eq!(node_import_attributes(version), None, "{version}"); + } + } + + #[test] + fn node_recovery_does_not_rewrite_shell_shims_or_pinned_interpreters() { + let path = std::env::temp_dir().join(format!("sc-agent-shebang-{}", std::process::id())); + for header in ["#!/usr/bin/env node\n", "#!/bin/env node\r\n", "#!/usr/bin/env -S node\n"] { + std::fs::write(&path, header).unwrap(); + assert!(uses_env_node(&path), "{header}"); + } + for header in ["#!/bin/sh\n", "#!/opt/node/bin/node\n", "#!/usr/bin/env -S node --experimental-modules\n", "@echo off\r\n", "node\n"] { + std::fs::write(&path, header).unwrap(); + assert!(!uses_env_node(&path), "{header}"); + } + std::fs::remove_file(&path).unwrap(); + assert!(!uses_env_node(&path)); + } + #[test] fn ollama_capabilities_distinguish_tools_chat_and_unknown() { use serde_json::json; diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 7d3717730a..e65e28f636 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -195,6 +195,13 @@ shows its **version** (from `--version`, given three seconds) and whether a **lo Version checks and agent sessions also use those folders on their `PATH`, so adapters can find runtimes such as Node when SolidCraft is opened from Finder or the Dock. Existing absolute `PATH` entries keep their order; no shell startup files are run. +If a detected Node adapter fails with `SyntaxError: Unexpected token 'with'` on an older Node, +SolidCraft checks these folders for a Node version that supports import attributes. For an +adapter with an `env node` shebang, it retries with that Node and keeps the same interpreter +for the chat session, including programs the adapter starts. These extra checks run in the +background with a shared three-second limit for finding Node. Working adapters keep their +original launch command. If no suitable Node is found, the agent's error names the old version +and asks you to update Node and press **Refresh**. SolidCraft only checks whether the agent's own login file (`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, …) or one of its key variables exists. It never reads the file and never starts the agent to find out. On macOS Claude Code keeps its login in