diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index aedf2696..ecb2f3ca 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "1.27.0" + version: "1.28.0" --- # Netclaw Operations @@ -554,6 +554,7 @@ What to expect inside `session.log`: | Missing tools | `netclaw mcp list`; check MCP connection state | | Memory recall degraded | `netclaw status` memory section | | Daemon won't start | crash logs at `~/.netclaw/logs/crash-*.log` | +| `command not found` for `netclaw` from shell tool when daemon runs as systemd service | `netclaw doctor` (the **Systemd Unit PATH** check warns when the unit was installed before PATH was baked in) | If webhook notifications are configured, daemon crash paths emit `daemon.crashing` operational alerts with context (PID, reason, and latest known diff --git a/src/Netclaw.Cli.Tests/Doctor/SystemdUnitPathDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/SystemdUnitPathDoctorCheckTests.cs new file mode 100644 index 00000000..380acdb4 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Doctor/SystemdUnitPathDoctorCheckTests.cs @@ -0,0 +1,131 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Doctor; +using Xunit; + +namespace Netclaw.Cli.Tests.Doctor; + +public sealed class SystemdUnitPathDoctorCheckTests +{ + [Fact] + public async Task ReturnsPass_WhenPlatformDisabled() + { + var unitPath = WriteUnit("[Service]\nExecStart=/opt/netclaw/netclawd\n"); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: false); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Contains("Not applicable", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ReturnsPass_WhenUnitFileDoesNotExist() + { + var unitPath = Path.Combine(Path.GetTempPath(), "netclaw-tests", Guid.NewGuid().ToString("N"), "netclaw.service"); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: true); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Contains("No systemd user service installed", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ReturnsWarning_WhenPathDirectiveMissing() + { + var unitPath = WriteUnit(""" + [Unit] + Description=Netclaw Daemon + + [Service] + Type=simple + ExecStart=/opt/netclaw/netclawd + Environment=DOTNET_ENVIRONMENT=Production + """); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: true); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Warning, result.Severity); + Assert.Contains("does not set PATH", result.Message, StringComparison.Ordinal); + Assert.Contains("daemon uninstall", result.Remediation!, StringComparison.Ordinal); + } + + [Fact] + public async Task ReturnsWarning_WhenPathMissingInstallDir() + { + var unitPath = WriteUnit(""" + [Service] + ExecStart=/opt/netclaw/netclawd + Environment=PATH=/usr/local/bin:/usr/bin:/bin + """); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: true); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Warning, result.Severity); + Assert.Contains("does not include the daemon's install directory", result.Message, StringComparison.Ordinal); + Assert.Contains("/opt/netclaw", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ReturnsPass_WhenPathContainsInstallDir() + { + var unitPath = WriteUnit(""" + [Service] + ExecStart=/home/user/.local/bin/netclawd + Environment=PATH=/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin + """); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: true); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Contains("/home/user/.local/bin", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ReturnsWarning_WhenExecStartMissing() + { + var unitPath = WriteUnit(""" + [Service] + Type=simple + Environment=PATH=/usr/local/bin:/usr/bin:/bin + """); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: true); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Warning, result.Severity); + Assert.Contains("missing ExecStart", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ParsesExecStart_StrippingArguments() + { + // ExecStart with arguments — install directory is the binary's parent. + var unitPath = WriteUnit(""" + [Service] + ExecStart=/opt/netclaw/netclawd --foreground + Environment=PATH=/opt/netclaw:/usr/bin + """); + var check = new SystemdUnitPathDoctorCheck(unitPath, enabledOnThisPlatform: true); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + } + + private static string WriteUnit(string content) + { + var dir = Path.Combine(Path.GetTempPath(), "netclaw-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "netclaw.service"); + File.WriteAllText(path, content); + return path; + } +} diff --git a/src/Netclaw.Cli/Daemon/DaemonManager.cs b/src/Netclaw.Cli/Daemon/DaemonManager.cs index dc5b28fd..62e160fe 100644 --- a/src/Netclaw.Cli/Daemon/DaemonManager.cs +++ b/src/Netclaw.Cli/Daemon/DaemonManager.cs @@ -242,16 +242,23 @@ public async Task InstallAsync() "Cannot find netclawd binary. Set NETCLAW_DAEMON_PATH or ensure it is " + "in the same directory as the CLI."); - var unitDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", "systemd", "user"); - Directory.CreateDirectory(unitDir); + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + Directory.CreateDirectory(SystemdUserUnitDirectory); // CLI binary is in the same directory as the daemon binary var installDir = Path.GetDirectoryName(binaryPath)!; var cliBinaryPath = Path.Combine(installDir, "netclaw"); - var unitPath = Path.Combine(unitDir, "netclaw.service"); + // systemd --user services start with a sanitized PATH that does not include + // installDir or ~/.local/bin, so the agent's shell tool cannot resolve + // `netclaw` (or other user-installed binaries) when invoked from the daemon. + // We compose PATH explicitly: installDir first (so the daemon's bundled CLI + // wins), then ~/.local/bin (common user-bin location), then the systemd + // default. Keep this in sync with SystemdUnitPathDoctorCheck. + var unitPathEnv = ComposeSystemdUnitPath(installDir, userHome); + + var unitPath = SystemdUserUnitFilePath; + var isUpgrade = File.Exists(unitPath); var unitContent = $""" [Unit] Description=Netclaw Daemon @@ -264,6 +271,7 @@ public async Task InstallAsync() Restart=always RestartSec=5 Environment=DOTNET_ENVIRONMENT=Production + Environment=PATH={unitPathEnv} [Install] WantedBy=default.target @@ -284,8 +292,50 @@ public async Task InstallAsync() if (!linger.Success) return new DaemonResult(false, $"Service installed but linger failed: {linger.Message}"); - return new DaemonResult(true, - $"Service installed at {unitPath}. Start with: systemctl --user start netclaw"); + var startMessage = $"Service installed at {unitPath}. Start with: systemctl --user start netclaw"; + if (isUpgrade) + { + startMessage += "\nUnit file refreshed (PATH for the daemon's shell tool) — " + + "restart the service to pick up the change."; + } + + return new DaemonResult(true, startMessage); + } + + /// + /// Path to the systemd user-service directory (~/.config/systemd/user). + /// Single source of truth for daemon install/uninstall and + /// . + /// + internal static string SystemdUserUnitDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", "systemd", "user"); + + internal static string SystemdUserUnitFilePath => Path.Combine(SystemdUserUnitDirectory, "netclaw.service"); + + /// + /// Builds the PATH value baked into the systemd user unit so the daemon's + /// shell tool can resolve user-installed binaries like netclaw. + /// + /// + /// systemd --user services start with a minimal default PATH that does + /// not include ~/.local/bin or any custom install directory, so we + /// compose one explicitly. The doctor check + /// SystemdUnitPathDoctorCheck validates that an existing unit file + /// contains on PATH; keep both call sites in + /// agreement. + /// + internal static string ComposeSystemdUnitPath(string installDir, string userHome) + { + var localBin = Path.Combine(userHome, ".local", "bin"); + return string.Join(':', + installDir, + localBin, + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin"); } /// @@ -301,10 +351,7 @@ public async Task UninstallAsync() await RunCommandAsync("systemctl", "--user stop netclaw.service"); await RunCommandAsync("systemctl", "--user disable netclaw.service"); - var unitPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", "systemd", "user", "netclaw.service"); - + var unitPath = SystemdUserUnitFilePath; if (File.Exists(unitPath)) File.Delete(unitPath); diff --git a/src/Netclaw.Cli/Doctor/DoctorRegistrationExtensions.cs b/src/Netclaw.Cli/Doctor/DoctorRegistrationExtensions.cs index f3e62836..fb67cfcc 100644 --- a/src/Netclaw.Cli/Doctor/DoctorRegistrationExtensions.cs +++ b/src/Netclaw.Cli/Doctor/DoctorRegistrationExtensions.cs @@ -29,5 +29,6 @@ public static void AddDoctorChecks(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Netclaw.Cli/Doctor/SystemdUnitPathDoctorCheck.cs b/src/Netclaw.Cli/Doctor/SystemdUnitPathDoctorCheck.cs new file mode 100644 index 00000000..a9477c90 --- /dev/null +++ b/src/Netclaw.Cli/Doctor/SystemdUnitPathDoctorCheck.cs @@ -0,0 +1,158 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Daemon; + +namespace Netclaw.Cli.Doctor; + +/// +/// Validates that the systemd --user unit installed by +/// netclaw daemon install bakes a PATH that resolves the daemon's +/// install directory. Without this, ShellTool and +/// BackgroundJobExecutionActor spawn bash -c with the +/// sanitized systemd default PATH and cannot find netclaw, +/// ~/.local/bin tools, or anything else outside the system path. +/// +/// +/// This is a Linux-only diagnostic. On non-Linux platforms — and on Linux +/// boxes where the user runs netclaw daemon start directly instead +/// of installing the service — this check passes silently because the +/// daemon inherits the operator's interactive shell PATH and the failure +/// mode does not apply. +/// +public sealed class SystemdUnitPathDoctorCheck : IDoctorCheck +{ + private const string CheckName = "Systemd Unit PATH"; + private const string ExecStartPrefix = "ExecStart="; + private const string PathDirectivePrefix = "Environment=PATH="; + + private readonly string _unitFilePath; + private readonly bool _enabledOnThisPlatform; + + public SystemdUnitPathDoctorCheck() + : this(DaemonManager.SystemdUserUnitFilePath, OperatingSystem.IsLinux()) + { + } + + /// + /// Test seam: explicit unit path and platform gate so tests can exercise + /// the parser on any host without needing a real systemd installation. + /// + internal SystemdUnitPathDoctorCheck(string unitFilePath, bool enabledOnThisPlatform) + { + _unitFilePath = unitFilePath; + _enabledOnThisPlatform = enabledOnThisPlatform; + } + + public Task RunAsync(CancellationToken cancellationToken = default) + { + if (!_enabledOnThisPlatform) + return Task.FromResult(DoctorCheckResult.Pass(CheckName, "Not applicable on this platform.")); + + var unitPath = _unitFilePath; + + if (!File.Exists(unitPath)) + { + return Task.FromResult(DoctorCheckResult.Pass( + CheckName, + "No systemd user service installed (skipping).")); + } + + string[] lines; + try + { + lines = File.ReadAllLines(unitPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + $"Could not read {unitPath}: {ex.Message}", + "Check file permissions.")); + } + + var execStart = FindDirective(lines, ExecStartPrefix); + if (execStart is null) + { + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + $"{unitPath} is missing ExecStart=. Unit file may be malformed.", + "Reinstall: `netclaw daemon uninstall && netclaw daemon install`.")); + } + + // systemd unit paths are always POSIX-style; use forward-slash semantics + // regardless of host OS so the parser is portable across CI runners. + var binaryPath = ExtractFirstToken(execStart); + var lastSlash = binaryPath.LastIndexOf('/'); + var installDir = lastSlash > 0 ? binaryPath[..lastSlash] : string.Empty; + if (string.IsNullOrEmpty(installDir)) + { + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + $"Could not determine install directory from ExecStart in {unitPath}.", + "Reinstall: `netclaw daemon uninstall && netclaw daemon install`.")); + } + + var pathDirective = FindDirective(lines, PathDirectivePrefix); + if (pathDirective is null) + { + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + $"Systemd unit at {unitPath} does not set PATH. The daemon's shell tool will fail to resolve `netclaw`, " + + "`~/.local/bin` tools, and anything outside the systemd default PATH.", + "Reinstall to refresh the unit file: `netclaw daemon uninstall && netclaw daemon install`, " + + "then `systemctl --user restart netclaw`.")); + } + + var pathValue = pathDirective[PathDirectivePrefix.Length..]; + var entries = pathValue.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var hasInstallDir = entries.Any(e => string.Equals(e, installDir, StringComparison.Ordinal)); + + if (!hasInstallDir) + { + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + $"Systemd unit PATH at {unitPath} does not include the daemon's install directory ({installDir}). " + + "Shell tool invocations may fail to resolve `netclaw`.", + "Reinstall: `netclaw daemon uninstall && netclaw daemon install`.")); + } + + return Task.FromResult(DoctorCheckResult.Pass( + CheckName, + $"Systemd unit PATH includes {installDir} ({entries.Length} entries).")); + } + + /// + /// Returns the first line whose trimmed start matches , + /// stripped of leading whitespace. systemd unit files allow whitespace before + /// directives; we accept it. Returns null if no match exists. + /// + private static string? FindDirective(string[] lines, string prefix) + { + foreach (var rawLine in lines) + { + var line = rawLine.TrimStart(); + if (line.StartsWith(prefix, StringComparison.Ordinal)) + return line; + } + + return null; + } + + /// + /// Extracts the first whitespace-delimited token from a directive value + /// (e.g., ExecStart=/path/to/netclawd --flag/path/to/netclawd). + /// + private static string ExtractFirstToken(string directive) + { + var equalsIndex = directive.IndexOf('='); + if (equalsIndex < 0 || equalsIndex == directive.Length - 1) + return string.Empty; + + var value = directive[(equalsIndex + 1)..].TrimStart(); + var spaceIndex = value.IndexOf(' '); + return spaceIndex < 0 ? value : value[..spaceIndex]; + } +}