From fbb63161f1da0879235d7e2bf561af4743872fd0 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:00:09 -0300 Subject: [PATCH] fix(runtime): run the native binary of an npm install of Claude Code The npm package (seen with 2.1.287) ships the native claude.exe under node_modules/@anthropic-ai/claude-code/bin, and its claude.cmd/.ps1 only call it. The daemon refused the launcher and stopped there, so owners who installed Claude Code with npm saw it as missing. - a claude.cmd/.ps1 resolves to that binary, unless it is still the small placeholder the npm postinstall replaces - a launcher that cannot run no longer stops the search; later PATH entries are still tried - on Windows, also look in %USERPROFILE%\.local\bin, WinGet Links and %APPDATA%\npm, for a PATH set only in a shell profile The lookup moves to runtime/claude/locate.rs to keep claude.rs short. Spec 7.4 and section 19 updated. --- crates/botloftd/src/runtime/claude.rs | 128 +--------- crates/botloftd/src/runtime/claude/locate.rs | 242 +++++++++++++++++++ docs/spec.md | 3 +- 3 files changed, 249 insertions(+), 124 deletions(-) create mode 100644 crates/botloftd/src/runtime/claude/locate.rs diff --git a/crates/botloftd/src/runtime/claude.rs b/crates/botloftd/src/runtime/claude.rs index a8461826..a4b6621d 100644 --- a/crates/botloftd/src/runtime/claude.rs +++ b/crates/botloftd/src/runtime/claude.rs @@ -10,6 +10,10 @@ use botloft_core::protocol::ClaudeAccount; use super::AuthStatus; +mod locate; + +pub use self::locate::locate; + /// First version with the inbox as a named pipe on native Windows. pub const MIN_VERSION: (u32, u32, u32) = (2, 1, 234); const PROBE_TIMEOUT: Duration = Duration::from_secs(15); @@ -26,7 +30,7 @@ pub enum ClaudeError { #[error("Claude Code was not found in PATH; install it or set claude_path in config.toml")] NotFound, #[error( - "{0} is the npm launcher; Botloft runs the native claude.exe. Install Claude Code with the native installer or point claude_path at the .exe" + "{0} is the npm launcher and its native claude.exe is missing (the npm postinstall did not run). Install Claude Code with the native installer or point claude_path at the .exe" )] Launcher(PathBuf), #[error("cannot run {path}: {source}")] @@ -39,78 +43,6 @@ pub enum ClaudeError { TooOld { found: String, needed: String }, } -/// The configured path, or the first `claude` executable on `PATH`. -pub fn locate(configured: &str, env: &[(OsString, OsString)]) -> Result { - let configured = configured.trim(); - if !configured.is_empty() { - return not_a_launcher(PathBuf::from(configured)); - } - let path_var = env - .iter() - .find(|(name, _)| name.to_string_lossy().eq_ignore_ascii_case("PATH")) - .map(|(_, value)| value.clone()) - .unwrap_or_default(); - let dirs: Vec = path_entries(&path_var.to_string_lossy()) - .chain(usual_places(env)) - .collect(); - for dir in dirs { - for name in CANDIDATES { - let candidate = dir.join(name); - if candidate.is_file() { - return not_a_launcher(candidate); - } - } - } - Err(ClaudeError::NotFound) -} - -/// Splits `PATH` the way Windows searches it: on `;` only. A stray quote in -/// one entry (common on real systems) must not swallow the rest, which -/// `std::env::split_paths` does because it treats quotes as grouping. -fn path_entries(path: &str) -> impl Iterator + '_ { - let separator = if cfg!(windows) { ';' } else { ':' }; - path.split(separator) - .map(|entry| entry.trim().trim_matches('"')) - .filter(|entry| !entry.is_empty()) - .map(PathBuf::from) -} - -/// Where installers put Claude Code off Windows, for a profile that never -/// added it to `PATH`: the native installer, the older local install and -/// Homebrew. -fn usual_places(env: &[(OsString, OsString)]) -> Vec { - if cfg!(windows) { - return Vec::new(); - } - let home = env - .iter() - .find(|(name, _)| name == "HOME") - .map(|(_, value)| PathBuf::from(value)); - let mut places: Vec = home - .iter() - .flat_map(|home| [home.join(".local/bin"), home.join(".claude/local")]) - .collect(); - places.extend(["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)); - places -} - -#[cfg(windows)] -const CANDIDATES: &[&str] = &["claude.exe", "claude.cmd"]; -#[cfg(not(windows))] -const CANDIDATES: &[&str] = &["claude"]; - -/// `claude.cmd` needs `cmd.exe` in between, which mangles the quoting of -/// arguments passed through the pseudo terminal; only real executables run. -fn not_a_launcher(path: PathBuf) -> Result { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()); - match ext.as_deref() { - Some("cmd" | "bat" | "ps1") => Err(ClaudeError::Launcher(path)), - _ => Ok(path), - } -} - /// Runs `claude --version` and checks it against [`MIN_VERSION`]. pub fn probe(path: &Path, env: &[(OsString, OsString)]) -> Result { let output = run(path, &["--version"], env).map_err(|source| ClaudeError::Run { @@ -246,54 +178,4 @@ mod tests { ); assert_eq!(parse_auth_status(b"Logged in"), None); } - - #[test] - fn refuses_npm_launchers() { - assert!(matches!( - locate(r"C:\npm\claude.cmd", &[]), - Err(ClaudeError::Launcher(_)) - )); - assert_eq!( - locate(r"C:\bin\claude.exe", &[]).expect("configured path"), - PathBuf::from(r"C:\bin\claude.exe") - ); - } - - #[cfg(windows)] - #[test] - fn a_stray_quote_in_path_does_not_hide_later_entries() { - let path = r#"C:\A";C:\B\;;"C:\Quoted Dir";C:\Users\me\.local\bin"#; - let entries: Vec = path_entries(path).collect(); - let expected = [ - r"C:\A", - r"C:\B\", - r"C:\Quoted Dir", - r"C:\Users\me\.local\bin", - ]; - assert_eq!(entries, expected.map(PathBuf::from)); - } - - #[test] - fn searches_the_given_path() { - let dir = tempfile::tempdir().expect("tempdir"); - let exe = dir.path().join(CANDIDATES[0]); - std::fs::write(&exe, b"").expect("fake exe"); - let env = vec![(OsString::from("Path"), dir.path().as_os_str().to_owned())]; - assert_eq!(locate("", &env).expect("found"), exe); - assert!(matches!(locate("", &[]), Err(ClaudeError::NotFound))); - } - - #[cfg(unix)] - #[test] - fn finds_the_native_install_off_path() { - let home = tempfile::tempdir().expect("tempdir"); - let bin = home.path().join(".local/bin"); - std::fs::create_dir_all(&bin).expect("bin"); - std::fs::write(bin.join("claude"), b"").expect("fake claude"); - let env = vec![ - (OsString::from("PATH"), OsString::from("/nowhere")), - (OsString::from("HOME"), home.path().as_os_str().to_owned()), - ]; - assert_eq!(locate("", &env).expect("found"), bin.join("claude")); - } } diff --git a/crates/botloftd/src/runtime/claude/locate.rs b/crates/botloftd/src/runtime/claude/locate.rs new file mode 100644 index 00000000..1da059d7 --- /dev/null +++ b/crates/botloftd/src/runtime/claude/locate.rs @@ -0,0 +1,242 @@ +//! Where Claude Code is installed (spec 7.4). + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use super::ClaudeError; + +/// The configured path, or the first Claude Code executable on `PATH` or in +/// the places its installers use. +pub fn locate(configured: &str, env: &[(OsString, OsString)]) -> Result { + let configured = configured.trim(); + if !configured.is_empty() { + return executable(PathBuf::from(configured)); + } + let path_var = var(env, "PATH").unwrap_or_default(); + let dirs: Vec = path_entries(&path_var.to_string_lossy()) + .chain(usual_places(env)) + .collect(); + // A launcher that cannot run is reported only when nothing later can. + let mut launcher = None; + for dir in dirs { + for name in CANDIDATES { + let candidate = dir.join(name); + if !candidate.is_file() { + continue; + } + match executable(candidate) { + Ok(path) => return Ok(path), + Err(err) => { + launcher.get_or_insert(err); + } + } + } + } + Err(launcher.unwrap_or(ClaudeError::NotFound)) +} + +/// The value of `name` in `env`; Windows names ignore case. +fn var(env: &[(OsString, OsString)], name: &str) -> Option { + env.iter() + .find(|(key, _)| { + let key = key.to_string_lossy(); + if cfg!(windows) { + key.eq_ignore_ascii_case(name) + } else { + key == name + } + }) + .map(|(_, value)| value.clone()) +} + +/// Splits `PATH` the way Windows searches it: on `;` only. A stray quote in +/// one entry (common on real systems) must not swallow the rest, which +/// `std::env::split_paths` does because it treats quotes as grouping. +fn path_entries(path: &str) -> impl Iterator + '_ { + let separator = if cfg!(windows) { ';' } else { ':' }; + path.split(separator) + .map(|entry| entry.trim().trim_matches('"')) + .filter(|entry| !entry.is_empty()) + .map(PathBuf::from) +} + +/// Where installers put Claude Code, for a profile that never added it to +/// `PATH` (or added it only in a shell profile). Windows: the native +/// installer, WinGet and npm. Elsewhere: the native installer, the older +/// local install and Homebrew. +fn usual_places(env: &[(OsString, OsString)]) -> Vec { + let under = |name: &str, rest: &str| var(env, name).map(|base| PathBuf::from(base).join(rest)); + if cfg!(windows) { + return [ + under("USERPROFILE", r".local\bin"), + under("LOCALAPPDATA", r"Microsoft\WinGet\Links"), + under("APPDATA", "npm"), + ] + .into_iter() + .flatten() + .collect(); + } + let mut places: Vec = [under("HOME", ".local/bin"), under("HOME", ".claude/local")] + .into_iter() + .flatten() + .collect(); + places.extend(["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from)); + places +} + +#[cfg(windows)] +const CANDIDATES: &[&str] = &["claude.exe", "claude.cmd"]; +#[cfg(not(windows))] +const CANDIDATES: &[&str] = &["claude"]; + +/// `path` when it is a real executable. An npm launcher (`claude.cmd`) needs +/// `cmd.exe` in between, which mangles the quoting of arguments passed +/// through the pseudo terminal; the native binary the npm package installs +/// next to it runs instead. +fn executable(path: PathBuf) -> Result { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("cmd" | "bat" | "ps1")) { + return Ok(path); + } + let native = path.parent().and_then(npm_native); + native.ok_or(ClaudeError::Launcher(path)) +} + +/// Where the npm package keeps the native binary, relative to the folder +/// with its launchers (seen with 2.1.287). +const NPM_NATIVE: &[&str] = &[ + "node_modules", + "@anthropic-ai", + "claude-code", + "bin", + "claude.exe", +]; +/// The package ships a small placeholder script there, which its +/// postinstall replaces with the binary; a failed or skipped postinstall +/// leaves the placeholder. +const PLACEHOLDER_MAX: u64 = 4096; + +/// The native binary of an npm install in `dir`, if postinstall put it there. +fn npm_native(dir: &Path) -> Option { + let exe = dir.join(NPM_NATIVE.iter().collect::()); + let size = std::fs::metadata(&exe).ok()?.len(); + (size >= PLACEHOLDER_MAX).then_some(exe) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An npm global folder with its launcher and, if `native` is given, a + /// `bin\claude.exe` of that many bytes. + fn npm_folder(native: Option) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("claude.cmd"), b"@echo off").expect("launcher"); + if let Some(size) = native { + let exe = dir.path().join(NPM_NATIVE.iter().collect::()); + std::fs::create_dir_all(exe.parent().expect("bin")).expect("bin"); + std::fs::write(&exe, vec![0u8; size as usize]).expect("native"); + } + dir + } + + #[test] + fn refuses_npm_launchers_without_a_native_binary() { + assert!(matches!( + locate(r"C:\npm\claude.cmd", &[]), + Err(ClaudeError::Launcher(_)) + )); + assert_eq!( + locate(r"C:\bin\claude.exe", &[]).expect("configured path"), + PathBuf::from(r"C:\bin\claude.exe") + ); + let placeholder = npm_folder(Some(500)); + let launcher = placeholder.path().join("claude.cmd"); + assert!(matches!( + locate(&launcher.to_string_lossy(), &[]), + Err(ClaudeError::Launcher(_)) + )); + } + + #[test] + fn runs_the_native_binary_of_an_npm_install() { + let npm = npm_folder(Some(PLACEHOLDER_MAX)); + let launcher = npm.path().join("claude.cmd"); + assert_eq!( + locate(&launcher.to_string_lossy(), &[]).expect("native"), + npm.path().join(NPM_NATIVE.iter().collect::()) + ); + } + + #[cfg(windows)] + #[test] + fn finds_npm_installs_and_looks_past_launchers_on_path() { + let npm = npm_folder(Some(PLACEHOLDER_MAX)); + let env = vec![(OsString::from("Path"), npm.path().as_os_str().to_owned())]; + assert_eq!( + locate("", &env).expect("native"), + npm.path().join(NPM_NATIVE.iter().collect::()) + ); + + let placeholder = npm_folder(Some(500)); + let native = tempfile::tempdir().expect("tempdir"); + std::fs::write(native.path().join("claude.exe"), b"").expect("fake exe"); + let path = format!( + "{};{}", + placeholder.path().display(), + native.path().display() + ); + let env = vec![(OsString::from("Path"), OsString::from(path))]; + assert_eq!( + locate("", &env).expect("later entry"), + native.path().join("claude.exe") + ); + + let env = vec![( + OsString::from("Path"), + placeholder.path().as_os_str().to_owned(), + )]; + assert!(matches!(locate("", &env), Err(ClaudeError::Launcher(_)))); + } + + #[cfg(windows)] + #[test] + fn a_stray_quote_in_path_does_not_hide_later_entries() { + let path = r#"C:\A";C:\B\;;"C:\Quoted Dir";C:\Users\me\.local\bin"#; + let entries: Vec = path_entries(path).collect(); + let expected = [ + r"C:\A", + r"C:\B\", + r"C:\Quoted Dir", + r"C:\Users\me\.local\bin", + ]; + assert_eq!(entries, expected.map(PathBuf::from)); + } + + #[test] + fn searches_the_given_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let exe = dir.path().join(CANDIDATES[0]); + std::fs::write(&exe, b"").expect("fake exe"); + let env = vec![(OsString::from("PATH"), dir.path().as_os_str().to_owned())]; + assert_eq!(locate("", &env).expect("found"), exe); + assert!(matches!(locate("", &[]), Err(ClaudeError::NotFound))); + } + + #[test] + fn finds_the_native_install_off_path() { + let home = tempfile::tempdir().expect("tempdir"); + let bin = home.path().join(".local").join("bin"); + std::fs::create_dir_all(&bin).expect("bin"); + let exe = bin.join(CANDIDATES[0]); + std::fs::write(&exe, b"").expect("fake claude"); + let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let env = vec![ + (OsString::from("PATH"), OsString::from("/nowhere")), + (OsString::from(home_var), home.path().as_os_str().to_owned()), + ]; + assert_eq!(locate("", &env).expect("found"), exe); + } +} diff --git a/docs/spec.md b/docs/spec.md index 680dd66f..683a3688 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -228,7 +228,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s ### 7.4 Spawn -1. Resolver o binário: `claude_path` ou PATH. Só o `claude.exe` nativo roda; o `claude.cmd` do npm é recusado com erro claro (passar por `cmd.exe` estraga o quoting dos argumentos). O PATH é separado só por `;`, como o Windows faz; aspas soltas numa entrada (comum em PATHs reais) não escondem as entradas seguintes. +1. Resolver o binário: `claude_path` ou PATH, e depois os lugares dos instaladores, para quem só pôs a pasta no PATH do perfil do shell (no Windows: `%USERPROFILE%\.local\bin` do instalador nativo, `%LOCALAPPDATA%\Microsoft\WinGet\Links` e `%APPDATA%\npm`). Só o `claude.exe` nativo roda: passar por `cmd.exe` estraga o quoting dos argumentos. Por isso, ao achar o `claude.cmd` (ou `.ps1`) do npm, o daemon usa o executável nativo que o pacote do npm põe ao lado, em `node_modules\@anthropic-ai\claude-code\bin\claude.exe` (19). Se esse arquivo ainda é o script pequeno que o pós-instalação do npm troca pelo binário (menos de 4 KB), o launcher é recusado, a busca segue pelas entradas seguintes e o erro claro só aparece se nada servir. O PATH é separado só por `;`, como o Windows faz; aspas soltas numa entrada (comum em PATHs reais) não escondem as entradas seguintes. 2. `probe`: `claude --version` e exigir **>= 2.1.234**. Sem Claude utilizável, os bots ficam `offline`, `system.status.runtimeError` diz o motivo e o daemon tenta de novo a cada 30 s. 3. Gerar os arquivos da seção 5.1. 4. Comando, com cwd no workspace: @@ -1002,6 +1002,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | `description` na entrada do `Bash` e do `PowerShell` | 10.1 | Confirmado (`hooks`, entrada do `PreToolUse`): nas duas ferramentas, `description` é "Optional description of what the command does", ao lado de `command`, `timeout` e `run_in_background`. **Testado com 2.1.284** em 2026-09-30 (`-p` com uma tool de aprovação que só grava a entrada e nega; depois com a linha de comando de 7.4; Haiku 4.5, Sonnet 5.5 e o padrão do plano, Opus 5.5): a tool de aprovação recebe a mesma entrada do bloco `tool_use`, e os 32 pedidos de `Bash` e `PowerShell` vistos traziam `description`. Sem regra, o texto é curto e técnico ("Install requests package"); com o dono escrevendo em português, Haiku e Opus descreveram em português e o Sonnet em inglês, também pelo daemon. Com a regra de 10.1, os três escreveram em português, em palavras simples e com o porquê ("Instala o pacote requests, que ajuda o Python a acessar sites"). Pelo daemon e pelo app de dev (Sonnet, modo Manual): o cartão mostrou a explicação, "Ver o comando" abriu o script em heredoc inteiro, e permitir rodou esse comando; o log de `debug` não teve o texto. **O campo não é garantido**: em uso real com 2.1.284, dois bots em Sonnet fizeram 28 comandos sem `description` (o `tool_use` já vinha só com `command`), e os outros bots, quase todos em Opus, mandaram nos 15 que fizeram. A falta não se repetiu em teste, então não foi visto se a regra a evita: o cartão tem o caso sem explicação | feito; manual (PR): ver se bots antigos em Sonnet passam a explicar depois de reiniciar | | `--permission-prompts host` | 10.1 | Documentado só para o SDK. **Visto com 2.1.284**: sem o aperto de mão do SDK, nega tudo (`system/permission_denied`). Não usado | não se aplica | | `timeout` por servidor MCP | 10 | Confirmado (`env-vars`): HTTP tem 60 s por request e 5 min sem resposta por padrão; `timeout` >= 1000 no servidor sobe os dois. **Testado com 2.1.284**: a aprovação respondida depois de 95 s chegou ao bot | feito (M4.1) | +| Binário nativo na instalação do npm | 7.4 | **Visto com 2.1.287** (pacote `@anthropic-ai/claude-code` no npm): o `bin` do pacote é `bin/claude.exe`, um script de 500 bytes que o `postinstall` (`install.cjs`) troca pelo binário do pacote opcional da plataforma (`@anthropic-ai/claude-code-win32-x64`); com `--omit=optional` ou `--ignore-scripts`, o script fica. O `claude.cmd` e o `claude.ps1` do npm só chamam esse `.exe`. Numa instalação real no Windows, `bin\claude.exe --version` respondeu `2.1.287 (Claude Code)`, e o Botloft rodou com `claude_path` apontando para ele. Não documentado: o caminho pode mudar numa versão futura | feito; manual (PR): instalar só pelo npm e ver o Botloft achar o Claude Code sem `claude_path` | | `--setting-sources project,local` | 7.4 | Confirmado (`cli-reference`). **Testado com 2.1.284**: sem os hooks, skills e agents do usuário; modo `default`; login da assinatura continua valendo | feito (M4.1) | | `claude auth status` | 7.3 | Confirmado (`cli-reference`): JSON por padrão, sai com 0 conectado e 1 desconectado. **Testado com 2.1.284**: conectado traz `"loggedIn": true`; com `CLAUDE_CONFIG_DIR` vazio, `"loggedIn": false`, `"authMethod": "none"` e saída 1. As credenciais ficam em `%USERPROFILE%\.claude\.credentials.json` (`authentication`) | feito | | `claude auth login` | 15.2 | Confirmado (`cli-reference`, `authentication`): abre o navegador e volta por um servidor local; se o navegador não alcançar esse servidor, mostra um código para colar no terminal. Se o subcomando sai sozinho depois do login ainda não foi visto: o app só depende do código de saída | manual (PR) |