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];
+ }
+}