Merge main (ea772a0): #11351 squashed, which this branch already holds, and #11255 (Agent chat: a compatible Node for discovered adapters)

Main's tree is #11351's head (084695416, merged here earlier) plus #11255
and its regenerated catalogs, so main is recorded as merged and only
those changes are applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
This commit is contained in:
jyviko
2026-11-02 18:21:44 -05:00
10 changed files with 202 additions and 7 deletions
+1
View File
@@ -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": "غير مثبت",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "לא מותקן",
+1
View File
@@ -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": "未インストール",
+1
View File
@@ -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": "설치되지 않음",
+1
View File
@@ -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",
+1
View File
@@ -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": "未安装",
+187 -7
View File
@@ -275,7 +275,9 @@ pub fn runnable_sibling(program: &Path) -> Option<PathBuf> {
/// 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<String>,
/// A failed JSON import-attributes parse on a Node version that cannot support it.
old_node: Option<String>,
}
/// 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<bool> {
let mut parts = version.strip_prefix('v')?.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let _patch = parts.next()?.parse::<u64>().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<PathBuf> {
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<bool> {
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::<serde_json::Value>(&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;
+7
View File
@@ -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