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
2 changes: 2 additions & 0 deletions docs/spec/SPEC-004-cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ Behavior:
- optional output: JSON for automation
- exit code `0` for success
- exit code `1` for validation, policy, or runtime failures
- expected model-configuration migration failures are validation failures: print actionable
output, return exit code `1`, and do not create crash logs or emit stack traces
- exit code `2` for usage and argument errors

## Safety Rules
Expand Down
3 changes: 2 additions & 1 deletion docs/spec/SPEC-011-daemon-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ The daemon installs process-level exception handlers at startup for:
- `AppDomain.CurrentDomain.UnhandledException`
- `TaskScheduler.UnobservedTaskException`

On either path, Netclaw writes a crash log under `~/.netclaw/logs/crash-*.log`
On either path, Netclaw writes a crash log under `<NETCLAW_HOME>/logs/crash-*.log`;
`NETCLAW_HOME` defaults to `~/.netclaw`
with process diagnostics and the latest known session/turn context. When DI is
available, the daemon also emits an operational alert with type
`daemon.crashing` (category `DaemonCrashed`) so configured webhook targets can
Expand Down
2 changes: 1 addition & 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: "2.31.0"
version: "2.32.0"
---

# Netclaw Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ When something seems wrong with Netclaw itself:
2. If doctor reports fixable issues, run `netclaw doctor --fix --dry-run` to
preview auto-repairs (schema-driven: stale properties, enum coercion, missing defaults)
3. Run `netclaw status` via `shell_execute` — live runtime state from daemon
3. Check daemon logs at `~/.netclaw/logs/daemon-{yyyy-MM-dd}.log`
4. Check session logs at `~/.netclaw/logs/sessions/{sanitized-session-id}/session.log`
3. Check daemon logs at `<NETCLAW_HOME>/logs/daemon-{yyyy-MM-dd}.log` (`NETCLAW_HOME` defaults to `~/.netclaw`)
4. Check session logs at `<NETCLAW_HOME>/logs/sessions/{sanitized-session-id}/session.log`

If `netclaw status` or `netclaw chat` prints `daemon not configured - please run
netclaw init`, do not troubleshoot daemon reachability or model defaults. The
Expand Down Expand Up @@ -75,7 +75,7 @@ debugging a daemon-wide problem → read `daemon.log`.
| No LLM responses | `netclaw doctor`; verify provider credentials |
| 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` |
| Daemon won't start | crash logs at `<NETCLAW_HOME>/logs/crash-*.log` (`NETCLAW_HOME` defaults to `~/.netclaw`) |
| Docker daemon cannot create `/home/netclaw/.netclaw/*` | Official image entrypoint repairs writable bind mounts to UID/GID `1654:1654`; if bypassed or read-only, run `sudo chown -R 1654:1654 <host-data-dir>` or use a Docker named volume |
| Discord/Slack channel offline | `netclaw status` shows the channel `disconnected` with a reason. Discord may also report `degraded` when Discord.Net says the socket is connected but the gateway is not ready, such as after a resumed session that Netclaw is replacing with a clean reconnect. A misconfigured channel (bad token, missing Discord Message Content intent) degrades only that channel — the daemon keeps running and other channels are unaffected. A transient network failure retries automatically; a config/permission failure stays offline until the operator fixes the config and restarts the daemon. |
| `command not found` for `netclaw`/`dotnet`/a user tool from the shell tool when the daemon runs as a systemd service | The systemd `--user` service does not inherit your login-shell `PATH`; `netclaw daemon install` captures it into `~/.netclaw/config/daemon.env`. Run `netclaw doctor` (the **Systemd Unit PATH** check flags a missing/stale/legacy env file), then `netclaw doctor --fix` to rehydrate `PATH` from your current shell (or re-run `netclaw daemon install`), and finally `systemctl --user restart netclaw`. Installed a new tool after install? Its dir won't be seen until you re-run one of those and restart. Per-directory managers (`mise`/`asdf`/`direnv`) are not captured. |
Expand Down
80 changes: 80 additions & 0 deletions src/Netclaw.Cli.Tests/Doctor/DoctorFixServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
// -----------------------------------------------------------------------
using Netclaw.Cli.Daemon;
using System.Text.Json;
using Netclaw.Cli.Config;
using Netclaw.Cli.Doctor;
using Netclaw.Configuration;
using Xunit;

namespace Netclaw.Cli.Tests.Doctor;

[Collection(Netclaw.Cli.Tests.LegacyModelEnvironmentCollection.Name)]
public sealed class DoctorFixServiceTests
{
// POSIX install dir: systemd units are always POSIX-style regardless of the host OS
Expand Down Expand Up @@ -283,6 +285,84 @@ public async Task DoesNotThrow_WhenUnitEnvironmentFilePathIsMalformed()
Assert.DoesNotContain(plan.Fixes, f => f.FilePath == paths.DaemonEnvironmentFilePath);
}

[Fact]
public async Task LegacyEnvironmentOverride_BlocksMigrationWithoutChangingConfig()
{
var paths = NewPaths();
const string config =
"""
{
"configVersion": 1,
"Models": {
"Main": {
"Provider": "vllm",
"ModelId": "qwen-vl"
}
}
}
""";
await File.WriteAllTextAsync(paths.NetclawConfigPath, config, TestContext.Current.CancellationToken);
const string envVar = "NETCLAW_Models__Main__ContextWindow";
var previous = Environment.GetEnvironmentVariable(envVar);

try
{
Environment.SetEnvironmentVariable(envVar, "65536");
var service = ConfigOnlyService(paths);

var exception = await Assert.ThrowsAsync<ModelConfigurationException>(
() => service.BuildPlanAsync(TestContext.Current.CancellationToken));

Assert.Contains(envVar, exception.Message, StringComparison.Ordinal);
Assert.Equal(config, await File.ReadAllTextAsync(
paths.NetclawConfigPath, TestContext.Current.CancellationToken));
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}

[Fact]
public async Task LegacyEnvironmentOverride_DoesNotBlockAlreadyNamedModels()
{
var paths = NewPaths();
const string config =
"""
{
"configVersion": 1,
"Models": {
"Definitions": {
"main": {
"Provider": "vllm",
"ModelId": "qwen-vl"
}
},
"Roles": {
"Main": "main"
}
}
}
""";
await File.WriteAllTextAsync(paths.NetclawConfigPath, config, TestContext.Current.CancellationToken);
const string envVar = "NETCLAW_Models__Main__ContextWindow";
var previous = Environment.GetEnvironmentVariable(envVar);

try
{
Environment.SetEnvironmentVariable(envVar, "65536");
var service = ConfigOnlyService(paths);

var plan = await service.BuildPlanAsync(TestContext.Current.CancellationToken);

Assert.DoesNotContain(plan.Fixes, fix => fix.FilePath == paths.NetclawConfigPath);
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}

private static NetclawPaths NewPaths()
{
var paths = new NetclawPaths(CreateTempBasePath());
Expand Down
110 changes: 110 additions & 0 deletions src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace Netclaw.Cli.Tests.Model;

[Collection(Netclaw.Cli.Tests.LegacyModelEnvironmentCollection.Name)]
public sealed class ModelCommandTests : IDisposable
{
private readonly DisposableTempDir _dir = new();
Expand Down Expand Up @@ -618,6 +619,115 @@ public async Task Set_CorruptModalityButValidWindow_PreservesWindowEndToEnd()
Assert.False(main.TryGetProperty("InputModalities", out _)); // corrupt override dropped
}

[Fact]
public async Task Set_LegacyEnvironmentOverride_ReturnsErrorWithoutChangingConfig()
{
var config = ProvidersOnly();
config["Models"] = new Dictionary<string, object>
{
["Main"] = new Dictionary<string, object>
{
["Provider"] = "my-ollama",
["ModelId"] = "qwen3:30b"
}
};
WriteConfig(config);
var original = File.ReadAllText(_paths.NetclawConfigPath);
const string envVar = "NETCLAW_Models__Main__ContextWindow";
var previous = Environment.GetEnvironmentVariable(envVar);

try
{
Environment.SetEnvironmentVariable(envVar, "65536");

var exitCode = await ModelCommand.RunAsync(
["model", "set", "main", "my-ollama", "qwen3:8b"], _paths, output: _output);

Assert.Equal(1, exitCode);
Assert.Contains(
$"Error: Cannot migrate Models while legacy environment override '{envVar}' is set.",
_output.ToString(), StringComparison.Ordinal);
Assert.Equal(original, File.ReadAllText(_paths.NetclawConfigPath));
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}

[Fact]
public async Task Set_ConflictingLegacyRoles_ReturnsErrorWithoutChangingConfig()
{
var config = ProvidersOnly();
config["Models"] = new Dictionary<string, object>
{
["Main"] = new Dictionary<string, object>
{
["Provider"] = "my-ollama",
["ModelId"] = "qwen3:30b",
["ContextWindow"] = 32768
},
["Fallback"] = new Dictionary<string, object>
{
["Provider"] = "my-ollama",
["ModelId"] = "qwen3:30b",
["ContextWindow"] = 65536
}
};
WriteConfig(config);
var original = File.ReadAllText(_paths.NetclawConfigPath);

var exitCode = await ModelCommand.RunAsync(
["model", "set", "compaction", "my-ollama", "qwen3:30b"], _paths, output: _output);

Assert.Equal(1, exitCode);
Assert.Contains(
"Error: Legacy model roles conflict for my-ollama/qwen3:30b; align their metadata before migration.",
_output.ToString(), StringComparison.Ordinal);
Assert.Equal(original, File.ReadAllText(_paths.NetclawConfigPath));
}

[Fact]
public async Task Clear_LegacyEnvironmentOverride_ReturnsErrorWithoutChangingConfig()
{
var config = ProvidersOnly();
config["Models"] = new Dictionary<string, object>
{
["Main"] = new Dictionary<string, object>
{
["Provider"] = "my-ollama",
["ModelId"] = "qwen3:30b"
},
["Fallback"] = new Dictionary<string, object>
{
["Provider"] = "my-ollama",
["ModelId"] = "qwen3:8b"
}
};
WriteConfig(config);
var original = File.ReadAllText(_paths.NetclawConfigPath);
const string envVar = "NETCLAW_Models__Fallback__ContextWindow";
var previous = Environment.GetEnvironmentVariable(envVar);

try
{
Environment.SetEnvironmentVariable(envVar, "65536");

var exitCode = await ModelCommand.RunAsync(
["model", "clear", "fallback"], _paths, output: _output);

Assert.Equal(1, exitCode);
Assert.Contains(
$"Error: Cannot migrate Models while legacy environment override '{envVar}' is set.",
_output.ToString(), StringComparison.Ordinal);
Assert.Equal(original, File.ReadAllText(_paths.NetclawConfigPath));
}
finally
{
Environment.SetEnvironmentVariable(envVar, previous);
}
}

private static Dictionary<string, object> WithMainEntry(Dictionary<string, object> main)
{
var config = ProvidersOnly();
Expand Down
9 changes: 1 addition & 8 deletions src/Netclaw.Cli/Config/ConfigFileHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,7 @@ private static void PreserveLegacyModelsBackup(string path, Dictionary<string, o
|| !oldModels.TryGetProperty("Main", out _))
return;

var legacyEnvironmentOverride = ModelEntryWriter.FindLegacyEnvironmentOverride();
if (legacyEnvironmentOverride is not null)
{
throw new InvalidOperationException(
$"Cannot migrate Models while legacy environment override '{legacyEnvironmentOverride}' is set. " +
"Move model overrides to NETCLAW_Models__Definitions__<name>__* and " +
"NETCLAW_Models__Roles__* first.");
}
ModelEntryWriter.ThrowIfLegacyEnvironmentOverride();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior is crashy because we moved the config sections around - better than being not-crashy.


var backupPath = path + ".legacy-models.bak";
if (!File.Exists(backupPath))
Expand Down
30 changes: 23 additions & 7 deletions src/Netclaw.Cli/Config/ModelEntryWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,24 @@ internal static bool MigrateLegacy(Dictionary<string, object> modelsSection)
return false;
if (!modelsSection.Keys.Any(key => key is "Main" or "Fallback" or "Compaction"))
return false;

ThrowIfLegacyEnvironmentOverride();
EnsureNamedShape(modelsSection);
return true;
}

internal static void ThrowIfLegacyEnvironmentOverride()
{
var legacyEnvironmentOverride = FindLegacyEnvironmentOverride();
if (legacyEnvironmentOverride is null)
return;

throw new ModelConfigurationException(
$"Cannot migrate Models while legacy environment override '{legacyEnvironmentOverride}' is set. " +
"Move model overrides to NETCLAW_Models__Definitions__<name>__* and " +
"NETCLAW_Models__Roles__* first.");
}

internal static bool ClearRole(Dictionary<string, object> modelsSection, string roleKey)
{
var (_, roles) = EnsureNamedShape(modelsSection);
Expand Down Expand Up @@ -123,12 +137,12 @@ private static (Dictionary<string, object> Definitions, Dictionary<string, objec
key is "Main" or "Fallback" or "Compaction");

if (hasNamed && hasLegacy)
throw new InvalidOperationException("Models configuration mixes legacy roles with Definitions/Roles.");
throw new ModelConfigurationException("Models configuration mixes legacy roles with Definitions/Roles.");

if (hasNamed)
{
if (!modelsSection.ContainsKey("Definitions") || !modelsSection.ContainsKey("Roles"))
throw new InvalidOperationException("Named Models configuration requires Definitions and Roles.");
throw new ModelConfigurationException("Named Models configuration requires Definitions and Roles.");

return (GetDictionary(modelsSection, "Definitions"), GetDictionary(modelsSection, "Roles"));
}
Expand All @@ -146,28 +160,28 @@ private static (Dictionary<string, object> Definitions, Dictionary<string, objec
{
if (string.Equals(role, overwrittenRole, StringComparison.OrdinalIgnoreCase))
continue;
throw new InvalidOperationException(
throw new ModelConfigurationException(
$"Models:{role} must explicitly declare Provider and ModelId before migration.");
}

ModelReference model;
try
{
model = ConfigFileHelper.DeserializeSection<ModelReference>(raw)
?? throw new InvalidOperationException($"Models:{role} could not be parsed.");
?? throw new ModelConfigurationException($"Models:{role} could not be parsed.");
}
catch (JsonException)
{
model = ReadLegacyIdentity(raw)
?? throw new InvalidOperationException($"Models:{role} could not be repaired.");
?? throw new ModelConfigurationException($"Models:{role} could not be repaired.");
}
var existingName = FindDefinition(definitions, model.Provider, model.ModelId);
if (existingName is not null)
{
var existing = ConfigFileHelper.DeserializeSection<ModelReference>(definitions[existingName])!;
if (!Equivalent(existing, model))
{
throw new InvalidOperationException(
throw new ModelConfigurationException(
$"Legacy model roles conflict for {model.Provider}/{model.ModelId}; " +
$"align their metadata before migration.");
}
Expand Down Expand Up @@ -219,7 +233,7 @@ private static Dictionary<string, object> GetDictionary(Dictionary<string, objec
return dictionary;
}

throw new InvalidOperationException($"Models:{key} must be an object.");
throw new ModelConfigurationException($"Models:{key} must be an object.");
}

private static string? FindDefinition(
Expand Down Expand Up @@ -486,6 +500,8 @@ internal static Dictionary<string, object> BuildModelEntry(
}
}

internal sealed class ModelConfigurationException(string message) : Exception(message);

/// <summary>
/// An operator's intent for an overridable, operator-owned model attribute on <c>model set</c> —
/// a modality set (<see cref="ModelModality"/>) or the context window (<see cref="int"/>). A plain
Expand Down
9 changes: 0 additions & 9 deletions src/Netclaw.Cli/Doctor/DoctorFixService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,6 @@ public Task<DoctorFixPlan> BuildPlanAsync(CancellationToken cancellationToken =

if (obj["Models"] is JsonObject modelsNode)
{
var legacyEnvironmentOverride = ModelEntryWriter.FindLegacyEnvironmentOverride();
if (legacyEnvironmentOverride is not null)
{
throw new InvalidOperationException(
$"Cannot migrate Models while legacy environment override '{legacyEnvironmentOverride}' is set. " +
"Move model overrides to NETCLAW_Models__Definitions__<name>__* and " +
"NETCLAW_Models__Roles__* first.");
}

var models = JsonSerializer.Deserialize<Dictionary<string, object>>(modelsNode.ToJsonString())!;
if (ModelEntryWriter.MigrateLegacy(models))
{
Expand Down
Loading
Loading