Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions src/Netclaw.Cli.Tests/Doctor/SystemdUnitPathDoctorCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// -----------------------------------------------------------------------
// <copyright file="SystemdUnitPathDoctorCheckTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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;
}
}
69 changes: 58 additions & 11 deletions src/Netclaw.Cli/Daemon/DaemonManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,23 @@ public async Task<DaemonResult> 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
Expand All @@ -264,6 +271,7 @@ public async Task<DaemonResult> InstallAsync()
Restart=always
RestartSec=5
Environment=DOTNET_ENVIRONMENT=Production
Environment=PATH={unitPathEnv}

[Install]
WantedBy=default.target
Expand All @@ -284,8 +292,50 @@ public async Task<DaemonResult> 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);
}

/// <summary>
/// Path to the systemd user-service directory (<c>~/.config/systemd/user</c>).
/// Single source of truth for daemon install/uninstall and
/// <see cref="SystemdUnitPathDoctorCheck"/>.
/// </summary>
internal static string SystemdUserUnitDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "systemd", "user");

internal static string SystemdUserUnitFilePath => Path.Combine(SystemdUserUnitDirectory, "netclaw.service");

/// <summary>
/// Builds the PATH value baked into the systemd user unit so the daemon's
/// shell tool can resolve user-installed binaries like <c>netclaw</c>.
/// </summary>
/// <remarks>
/// systemd <c>--user</c> services start with a minimal default PATH that does
/// not include <c>~/.local/bin</c> or any custom install directory, so we
/// compose one explicitly. The doctor check
/// <c>SystemdUnitPathDoctorCheck</c> validates that an existing unit file
/// contains <paramref name="installDir"/> on PATH; keep both call sites in
/// agreement.
/// </remarks>
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");
}

/// <summary>
Expand All @@ -301,10 +351,7 @@ public async Task<DaemonResult> 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);

Expand Down
1 change: 1 addition & 0 deletions src/Netclaw.Cli/Doctor/DoctorRegistrationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ public static void AddDoctorChecks(this IServiceCollection services)
services.AddSingleton<IDoctorCheck, WebhookFormatDoctorCheck>();
services.AddSingleton<IDoctorCheck, InboundWebhookRoutesDoctorCheck>();
services.AddSingleton<IDoctorCheck, ExposureModeDoctorCheck>();
services.AddSingleton<IDoctorCheck, SystemdUnitPathDoctorCheck>();
}
}
Loading
Loading