Skip to content

Commit d3fa5e7

Browse files
author
Paul C
committed
v24.46.1: AI claude-cli reachable as root + cluster node selector
klasSponsor installed Claude Code and ran `sudo claude login` on the admin node, but the AI still errored "claude not installed". Cause: WolfStack runs as root under systemd, whose default PATH (/usr/local/bin:/usr/bin:…) does NOT include ~/.local/bin — exactly where Claude Code's native installer drops the binary. So a valid root install at /root/.local/bin/claude was invisible to the bare `Command::new("claude")`. - find_claude_binary() (src/ai/mod.rs): resolves `claude` via PATH then the common install locations (/usr/local/bin, /usr/bin, ~/.local/bin, ~/.npm-global/bin, ~/.claude/local, /root/* equivalents, and nvm node dirs). call_claude_cli uses the resolved path. Clearer error when truly absent (explains root vs user, and that a cluster only needs it on one node). - AI node selector ("Run the CLI on" in Settings → AI Agent, claude-cli section): aiNodeUrl() routes the AI Agent tab + assistant chat (config/status/models/ test-connection/test-email/chat/action/alerts) through /api/nodes/{id}/proxy to a chosen node, persisted in localStorage. So `claude` only needs installing on the ONE node you point the AI at, not every node. The GitHub-issues AI badge/scheduler and WolfAgents surfaces intentionally stay local. Build green (-D warnings), Cargo.lock bumped, web assets parse-checked. The binary-discovery path and the proxied node selector are logic-verified but not yet run against klas's live cluster.
1 parent 90da39b commit d3fa5e7

5 files changed

Lines changed: 113 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "24.46.0"
3+
version = "24.46.1"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/ai/mod.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3290,6 +3290,44 @@ async fn call_local_no_tools(
32903290
///
32913291
/// Auth lives in the invoking user's `~/.claude`. WolfStack runs as root, so
32923292
/// the operator must have run `sudo claude login` first (surfaced in the UI).
3293+
/// Locate the `claude` (Claude Code) binary. WolfStack runs as root under
3294+
/// systemd, whose default PATH (/usr/local/bin:/usr/bin:…) does NOT include
3295+
/// `~/.local/bin` — exactly where Claude Code's native installer drops the
3296+
/// binary. So a perfectly valid `sudo claude` install lands at
3297+
/// /root/.local/bin/claude and a bare `Command::new("claude")` reports "not
3298+
/// installed". Check PATH first, then the common install locations (root's home
3299+
/// and a node-version-manager layout). Returns the resolved path, or None.
3300+
fn find_claude_binary() -> Option<String> {
3301+
// On PATH already? (`command -v` honours the inherited PATH.)
3302+
if let Ok(o) = std::process::Command::new("sh").arg("-c").arg("command -v claude").output() {
3303+
if o.status.success() {
3304+
let p = String::from_utf8_lossy(&o.stdout).trim().to_string();
3305+
if !p.is_empty() && std::path::Path::new(&p).exists() {
3306+
return Some(p);
3307+
}
3308+
}
3309+
}
3310+
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
3311+
let mut candidates = vec![
3312+
"/usr/local/bin/claude".to_string(),
3313+
"/usr/bin/claude".to_string(),
3314+
format!("{home}/.local/bin/claude"),
3315+
format!("{home}/.npm-global/bin/claude"),
3316+
format!("{home}/.claude/local/claude"),
3317+
// Fall back to /root explicitly in case HOME isn't set in the unit env.
3318+
"/root/.local/bin/claude".to_string(),
3319+
"/root/.npm-global/bin/claude".to_string(),
3320+
"/root/.claude/local/claude".to_string(),
3321+
];
3322+
// nvm-style: ~/.nvm/versions/node/<ver>/bin/claude
3323+
if let Ok(entries) = std::fs::read_dir(format!("{home}/.nvm/versions/node")) {
3324+
for e in entries.flatten() {
3325+
candidates.push(format!("{}/bin/claude", e.path().display()));
3326+
}
3327+
}
3328+
candidates.into_iter().find(|p| std::path::Path::new(p).is_file())
3329+
}
3330+
32933331
async fn call_claude_cli(
32943332
model: &str,
32953333
system: &str,
@@ -3322,7 +3360,17 @@ async fn call_claude_cli(
33223360

33233361
let mdl = if model.trim().is_empty() { "sonnet" } else { model.trim() };
33243362

3325-
let mut child = tokio::process::Command::new("claude")
3363+
// WolfStack runs as root; resolve the binary across PATH + common install
3364+
// dirs so a `~/.local/bin` install (the native installer's default) is found.
3365+
let claude_bin = find_claude_binary().ok_or_else(|| {
3366+
"The `claude` CLI isn't installed or reachable as root on this node. \
3367+
WolfStack runs as root, which has its own home and PATH — installing or \
3368+
logging in as your normal user is NOT enough. As root: install Claude \
3369+
Code, then run `sudo claude login`. In a cluster you only need this on \
3370+
the node you point the AI at (you don't need it on every node).".to_string()
3371+
})?;
3372+
3373+
let mut child = tokio::process::Command::new(&claude_bin)
33263374
.arg("-p")
33273375
.arg("--model").arg(mdl)
33283376
.arg("--allowedTools").arg("") // no tools — text only (security)
@@ -3332,11 +3380,7 @@ async fn call_claude_cli(
33323380
.stderr(std::process::Stdio::piped())
33333381
.kill_on_drop(true) // a timed-out `claude` is reaped, not orphaned
33343382
.spawn()
3335-
.map_err(|e| if e.kind() == std::io::ErrorKind::NotFound {
3336-
"The `claude` CLI isn't installed on this node. Install Claude Code and run `sudo claude login` (WolfStack runs as root, so root needs the session).".to_string()
3337-
} else {
3338-
format!("Failed to launch `claude`: {}", e)
3339-
})?;
3383+
.map_err(|e| format!("Failed to launch `{}`: {}", claude_bin, e))?;
33403384

33413385
// Write stdin from a separate task so it streams CONCURRENTLY with draining
33423386
// stdout/stderr — writing the whole prompt before reading output can

web/index.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6246,11 +6246,18 @@ <h4 style="margin:0 0 16px 0;font-size:14px;color:var(--accent-light);">LLM Prov
62466246
<div style="padding:10px 12px; border:1px solid var(--border); border-radius:6px; background:rgba(99,102,241,0.08); font-size:13px; line-height:1.5;">
62476247
<strong>Uses your Claude Pro/Max subscription</strong> via the local <code>claude</code> CLI — no API key, no per-use cost.
62486248
<ul style="margin:8px 0 0 18px; padding:0;">
6249-
<li>Install <strong>Claude Code</strong> on this node, then run <code>sudo claude login</code> (WolfStack runs as root, so root needs the session).</li>
6249+
<li>Install <strong>Claude Code</strong> as <strong>root</strong> on the chosen node, then run <code>sudo claude login</code> (WolfStack runs as root — installing/logging in as your normal user is not enough).</li>
62506250
<li>Set the <strong>Model</strong> below to <code>opus</code>, <code>sonnet</code>, or <code>haiku</code> (or a full <code>claude-*</code> id).</li>
62516251
<li>The assistant runs with <strong>all Claude Code tools disabled</strong> — it only returns text; WolfStack's own confirmed command flow is unchanged.</li>
62526252
</ul>
62536253
</div>
6254+
<div class="form-group" style="margin-top:12px; margin-bottom:0;">
6255+
<label for="ai-cli-node">Run the CLI on</label>
6256+
<select id="ai-cli-node" class="form-control" onchange="onAiCliNodeChange()">
6257+
<option value="">This node</option>
6258+
</select>
6259+
<small style="color:var(--text-muted); display:block; margin-top:4px;">The <code>claude</code> CLI only needs installing + <code>sudo claude login</code> on the <strong>one</strong> node you pick here — not every node. Settings, Test Connection, and the assistant chat all run on that node.</small>
6260+
</div>
62546261
</div>
62556262
<div class="form-group" style="margin-bottom:16px;">
62566263
<label>Gemini API Key</label>

web/js/app.js

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,47 @@ function configuratorNodeUrl(path) {
20032003
return apiUrl(path);
20042004
}
20052005

2006+
// Which node the AI (Settings → AI Agent, Test Connection, assistant chat)
2007+
// runs on. Empty = the page-serving node. Set via the "Run the CLI on" selector
2008+
// in the AI Agent tab so the `claude` CLI only needs installing on ONE node in a
2009+
// cluster (klasSponsor). Persisted so the choice survives reloads.
2010+
let _aiNodeId = '';
2011+
try { _aiNodeId = localStorage.getItem('ai_node_id') || ''; } catch (e) { _aiNodeId = ''; }
2012+
function aiNodeUrl(path) {
2013+
if (_aiNodeId) {
2014+
const cleanPath = path.replace(/^\/api\//, '');
2015+
return `/api/nodes/${encodeURIComponent(_aiNodeId)}/proxy/${cleanPath}`;
2016+
}
2017+
return path;
2018+
}
2019+
// Fill the AI node selector with the cluster's WolfStack nodes and restore the
2020+
// saved choice. Called when the AI Agent tab loads.
2021+
function populateAiCliNodes() {
2022+
const sel = document.getElementById('ai-cli-node');
2023+
if (!sel) return;
2024+
const nodes = (allNodes || []).filter(n => n.node_type === 'wolfstack');
2025+
const opts = ['<option value="">This node</option>'].concat(
2026+
nodes.filter(n => !n.is_self).map(n =>
2027+
`<option value="${escapeAttr(n.id)}">${escapeHtml(n.display_name || n.hostname || n.address)}</option>`)
2028+
);
2029+
sel.innerHTML = opts.join('');
2030+
// Restore the saved selection if that node still exists.
2031+
if (_aiNodeId && nodes.some(n => n.id === _aiNodeId)) {
2032+
sel.value = _aiNodeId;
2033+
} else {
2034+
_aiNodeId = '';
2035+
sel.value = '';
2036+
}
2037+
}
2038+
function onAiCliNodeChange() {
2039+
const sel = document.getElementById('ai-cli-node');
2040+
_aiNodeId = sel ? sel.value : '';
2041+
try { localStorage.setItem('ai_node_id', _aiNodeId); } catch (e) { /* ignore */ }
2042+
// Re-load config + status from the newly-selected node.
2043+
if (typeof loadAiConfig === 'function') loadAiConfig();
2044+
if (typeof loadAiStatus === 'function') loadAiStatus();
2045+
}
2046+
20062047
// Which node a cert action should run on. An explicit nodeId (cluster-scope
20072048
// rows in the SSL manager pass one) always wins; otherwise the configurator's
20082049
// selected node; '' means the page-serving node (the existing single-node
@@ -32042,7 +32083,7 @@ async function sendAiMessage() {
3204232083
}
3204332084
if (currentPage) chatPayload.view = currentPage;
3204432085

32045-
var resp = await fetch('/api/ai/chat', {
32086+
var resp = await fetch(aiNodeUrl('/api/ai/chat'), {
3204632087
method: 'POST',
3204732088
headers: { 'Content-Type': 'application/json' },
3204832089
body: JSON.stringify(chatPayload)
@@ -32177,7 +32218,7 @@ async function approveAiAction(actionId) {
3217732218

3217832219
// Mark the action as approved on the server first (required before terminal can fetch the command)
3217932220
try {
32180-
var approveResp = await fetch('/api/ai/action', {
32221+
var approveResp = await fetch(aiNodeUrl('/api/ai/action'), {
3218132222
method: 'POST',
3218232223
headers: { 'Content-Type': 'application/json' },
3218332224
body: JSON.stringify({ action_id: actionId, approved: true })
@@ -32213,7 +32254,7 @@ async function dismissAiAction(actionId) {
3221332254
if (btns) btns.innerHTML = '<span style="color:var(--text-muted);font-size:12px;">Dismissed</span>';
3221432255

3221532256
try {
32216-
await fetch('/api/ai/action', {
32257+
await fetch(aiNodeUrl('/api/ai/action'), {
3221732258
method: 'POST',
3221832259
headers: { 'Content-Type': 'application/json' },
3221932260
body: JSON.stringify({ action_id: actionId, approved: false })
@@ -32228,8 +32269,9 @@ function openAiSettings() {
3222832269
}
3222932270

3223032271
async function loadAiConfig() {
32272+
populateAiCliNodes();
3223132273
try {
32232-
var resp = await fetch('/api/ai/config');
32274+
var resp = await fetch(aiNodeUrl('/api/ai/config'));
3223332275
var cfg = await resp.json();
3223432276
var el;
3223532277
// agent_enabled defaults to true server-side; tolerate missing
@@ -32288,7 +32330,7 @@ async function fetchAiModels(provider, selectedModel) {
3228832330
}
3228932331
select.innerHTML = '<option value="">Loading models...</option>';
3229032332
try {
32291-
var resp = await fetch('/api/ai/models?provider=' + encodeURIComponent(provider));
32333+
var resp = await fetch(aiNodeUrl('/api/ai/models?provider=' + encodeURIComponent(provider)));
3229232334
var data = await resp.json();
3229332335
var models = data.models || [];
3229432336
select.innerHTML = '';
@@ -32424,7 +32466,7 @@ async function saveAiConfig() {
3242432466
if (model === null) return;
3242532467
var config = _buildAiConfigPayload(model);
3242632468
try {
32427-
var resp = await fetch('/api/ai/config', {
32469+
var resp = await fetch(aiNodeUrl('/api/ai/config'), {
3242832470
method: 'POST',
3242932471
headers: { 'Content-Type': 'application/json' },
3243032472
body: JSON.stringify(config)
@@ -32451,7 +32493,7 @@ async function saveAiConfig() {
3245132493

3245232494
async function loadAiStatus() {
3245332495
try {
32454-
var resp = await fetch('/api/ai/status');
32496+
var resp = await fetch(aiNodeUrl('/api/ai/status'));
3245532497
var status = await resp.json();
3245632498
var textEl = document.getElementById('ai-status-text');
3245732499
var detailEl = document.getElementById('ai-status-detail');
@@ -32479,7 +32521,7 @@ async function loadAiStatus() {
3247932521

3248032522
async function loadAiAlerts() {
3248132523
try {
32482-
var resp = await fetch('/api/ai/alerts');
32524+
var resp = await fetch(aiNodeUrl('/api/ai/alerts'));
3248332525
var alerts = await resp.json();
3248432526
var container = document.getElementById('ai-alerts-list');
3248532527
if (!container) return;
@@ -32546,15 +32588,15 @@ async function testAiConnection() {
3254632588
};
3254732589

3254832590
try {
32549-
await fetch('/api/ai/config', {
32591+
await fetch(aiNodeUrl('/api/ai/config'), {
3255032592
method: 'POST',
3255132593
headers: { 'Content-Type': 'application/json' },
3255232594
body: JSON.stringify(config)
3255332595
});
3255432596

3255532597
stepEl.textContent = 'Sending a 150-byte ping to ' + (config.provider === 'local' ? config.local_url : config.provider) + '…';
3255632598

32557-
var resp = await fetch('/api/ai/test-connection', {
32599+
var resp = await fetch(aiNodeUrl('/api/ai/test-connection'), {
3255832600
method: 'POST',
3255932601
headers: { 'Content-Type': 'application/json' },
3256032602
});
@@ -32589,7 +32631,7 @@ async function testAiConnection() {
3258932631

3259032632
async function sendTestEmail() {
3259132633
try {
32592-
var resp = await fetch('/api/ai/test-email', { method: 'POST' });
32634+
var resp = await fetch(aiNodeUrl('/api/ai/test-email'), { method: 'POST' });
3259332635
var data = await resp.json();
3259432636
if (data.error) {
3259532637
showModal('' + data.error, 'Email Error');

0 commit comments

Comments
 (0)