Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="$(MicrosoftExtensionsAIVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="$(MicrosoftAspNetCoreVersion)" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(MicrosoftAspNetCoreVersion)" />
Expand Down
12 changes: 0 additions & 12 deletions src/Netclaw.Cli/Config/ClientConfigFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,6 @@ internal sealed class ClientConfigFile
{
public string? Endpoint { get; init; }

public static string? ReadEndpoint(NetclawPaths paths)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Was unused and seemed superseded by the DaemonControlPlaneEndpointResolver.cs‎

{
if (!File.Exists(paths.ClientConfigPath))
return null;

var text = File.ReadAllText(paths.ClientConfigPath);
var config = JsonSerializer.Deserialize<ClientConfigFile>(text);
return string.IsNullOrWhiteSpace(config?.Endpoint)
? null
: config.Endpoint.TrimEnd('/');
}

public static void WriteEndpoint(NetclawPaths paths, string endpoint)
{
var dir = Path.GetDirectoryName(paths.ClientConfigPath);
Expand Down
15 changes: 1 addition & 14 deletions src/Netclaw.Cli/Daemon/DaemonApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,7 @@ internal DaemonApi(IHttpClientFactory factory, IConfiguration configuration)
/// Usable without DI for callers that don't have the CLI service provider.
/// </summary>
public static string ResolveEndpoint(NetclawPaths? paths = null)
{
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Migrated to be consistent and reuse the existing resolver

paths ??= new NetclawPaths();

return (Environment.GetEnvironmentVariable("NETCLAW_DAEMON_ENDPOINT")
?? ClientConfigFile.ReadEndpoint(paths)
?? ResolveDaemonConfigEndpoint(paths)
?? DefaultEndpoint).TrimEnd('/');
}

private static string? ResolveDaemonConfigEndpoint(NetclawPaths paths)
{
var daemonConfig = DaemonClientFactory.LoadDaemonConfig(paths);
return DaemonControlPlaneEndpointResolver.ResolveFallbackEndpoint(daemonConfig);
}
=> DaemonControlPlaneEndpointResolver.ResolveEndpoint(paths);

/// <summary>
/// The resolved daemon base endpoint (e.g. <c>http://127.0.0.1:5199</c>).
Expand Down
10 changes: 1 addition & 9 deletions src/Netclaw.Cli/Daemon/DaemonClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,7 @@ internal static ExposureMode ResolveExposureMode(NetclawPaths paths)
=> LoadDaemonConfig(paths).ExposureMode;

internal static DaemonConfig LoadDaemonConfig(NetclawPaths paths)
{
var config = new ConfigurationBuilder()
.AddJsonFile(paths.NetclawConfigPath, optional: true, reloadOnChange: false)
.AddJsonFile(paths.SecretsPath, optional: true, reloadOnChange: false)
.AddEnvironmentVariables("NETCLAW_")
.Build();

return DaemonConfig.BindFromConfiguration(config.GetSection("Daemon"));
}
=> DaemonControlPlaneEndpointResolver.LoadDaemonConfig(paths);

/// <summary>
/// Returns <c>true</c> if the endpoint resolves to a loopback address
Expand Down
64 changes: 64 additions & 0 deletions src/Netclaw.Configuration/DaemonControlPlaneEndpointResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Configuration;

namespace Netclaw.Configuration;

/// <summary>
Expand All @@ -13,6 +16,67 @@ public static class DaemonControlPlaneEndpointResolver
{
public static readonly string DefaultEndpoint = $"http://127.0.0.1:{DaemonConfig.DefaultPort}";

/// <summary>
/// Resolves the daemon control-plane endpoint using the standard precedence
/// shared by all in-tree thin clients (CLI, Web):
/// <list type="number">
/// <item><c>NETCLAW_DAEMON_ENDPOINT</c> environment variable</item>
/// <item><c>&lt;basePath&gt;/client/config.json</c> <c>Endpoint</c> field</item>
/// <item><c>Daemon</c> block of <c>&lt;basePath&gt;/config/netclaw.json</c></item>
/// <item><see cref="DefaultEndpoint"/></item>
/// </list>
/// </summary>
public static string ResolveEndpoint(NetclawPaths? paths = null)
{
paths ??= new NetclawPaths();

return (Environment.GetEnvironmentVariable("NETCLAW_DAEMON_ENDPOINT")
?? ReadClientConfigEndpoint(paths)
?? ResolveFallbackEndpoint(LoadDaemonConfig(paths))
?? DefaultEndpoint).TrimEnd('/');
}

/// <summary>
/// Loads the <see cref="DaemonConfig"/> from <c>netclaw.json</c> and
/// <c>secrets.json</c> using the same precedence as the daemon process.
/// </summary>
public static DaemonConfig LoadDaemonConfig(NetclawPaths paths)
{
var config = new ConfigurationBuilder()
.AddJsonFile(paths.NetclawConfigPath, optional: true, reloadOnChange: false)
.AddJsonFile(paths.SecretsPath, optional: true, reloadOnChange: false)
.AddEnvironmentVariables("NETCLAW_")
.Build();

return DaemonConfig.BindFromConfiguration(config.GetSection("Daemon"));
}

private static string? ReadClientConfigEndpoint(NetclawPaths paths)
{
if (!File.Exists(paths.ClientConfigPath))
return null;

try
{
var text = File.ReadAllText(paths.ClientConfigPath);
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return null;
if (!doc.RootElement.TryGetProperty("Endpoint", out var endpointProp))
return null;
var endpoint = endpointProp.GetString();
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.TrimEnd('/');
}
catch (JsonException)
{
return null;
}
catch (IOException)
{
return null;
}
Comment on lines +70 to +77
}

public static string ResolveFallbackEndpoint(DaemonConfig daemonConfig)
{
var host = NormalizeConnectHost(daemonConfig.Host);
Expand Down
76 changes: 76 additions & 0 deletions src/Netclaw.Configuration/DiscordConfigContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// -----------------------------------------------------------------------
// <copyright file="DiscordConfigContracts.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
namespace Netclaw.Configuration;

/// <summary>
/// Response for <c>GET /api/config/discord</c>.
/// </summary>
public sealed record GetDiscordConfigResponse
{
public bool Enabled { get; init; }

/// <summary>
/// The response never includes the bot token's plaintext.
/// </summary>
public bool BotTokenIsSet { get; init; }

public string? DefaultChannelId { get; init; }

public bool AllowDirectMessages { get; init; }

public bool MentionOnly { get; init; } = true;

public bool MentionRequiredInDm { get; init; }

public string[] AllowedChannelIds { get; init; } = [];

public string[] AllowedUserIds { get; init; } = [];

public Dictionary<string, string> ChannelAudiences { get; init; } = new(StringComparer.Ordinal);
}

/// <summary>
/// Request for <c>PUT /api/config/discord</c>.
/// </summary>
public sealed record PutDiscordConfigRequest
{
public bool Enabled { get; init; }

/// <summary>
/// <c>null</c> leaves the stored token untouched. Empty string clears the
/// token. Any other value replaces it.
/// </summary>
public string? BotToken { get; init; }

public string? DefaultChannelId { get; init; }

public bool AllowDirectMessages { get; init; }

public bool MentionOnly { get; init; } = true;

public bool MentionRequiredInDm { get; init; }

public string[] AllowedChannelIds { get; init; } = [];

public string[] AllowedUserIds { get; init; } = [];

public Dictionary<string, string> ChannelAudiences { get; init; } = new(StringComparer.Ordinal);
}

/// <summary>
/// Response for <c>PUT /api/config/discord</c>.
/// </summary>
public sealed record PutDiscordConfigResponse
{
public required string ConfigPath { get; init; }

public required string SecretsPath { get; init; }

/// <summary>
/// Discord settings take effect after the daemon restarts.
/// </summary>
public bool RestartRequired { get; init; } = true;
}
109 changes: 109 additions & 0 deletions src/Netclaw.Configuration/ModelCatalogContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// -----------------------------------------------------------------------
// <copyright file="ModelCatalogContracts.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
namespace Netclaw.Configuration;

/// <summary>
/// A single model entry returned from the catalog.
/// <para>
/// <c>InputModalities</c> and <c>OutputModalities</c> are arrays of modality
/// strings (e.g. <c>["Text", "Image"]</c>).
/// </para>
/// </summary>
public sealed record ModelCatalogEntry
{
public required string Provider { get; init; }

public required string ModelId { get; init; }

public required string DisplayName { get; init; }

public int? ContextWindow { get; init; }

public string[] InputModalities { get; init; } = [];

public string[] OutputModalities { get; init; } = [];

/// <summary>UI category badges such as "frontier", "fast", "local".</summary>
public string[] Badges { get; init; } = [];

public string? Notes { get; init; }
}

/// <summary>
/// Response for <c>GET /api/models</c>.
/// </summary>
public sealed record GetModelCatalogResponse
{
public required IReadOnlyList<ModelCatalogEntry> Models { get; init; }

public string? Warning { get; init; }
}

/// <summary>
/// A model reference projected onto the wire — omits internal types.
/// </summary>
public sealed record ModelSelectionReference
{
public required string Provider { get; init; }

public required string ModelId { get; init; }

public int? ContextWindow { get; init; }

public string? Provenance { get; init; }

public string? InputModalities { get; init; }

public string? OutputModalities { get; init; }
}

/// <summary>
/// Response for <c>GET /api/model/selection</c>.
/// </summary>
public sealed record GetModelSelectionResponse
{
public required ModelSelectionReference Main { get; init; }

public ModelSelectionReference? Fallback { get; init; }

public ModelSelectionReference? Compaction { get; init; }
}

/// <summary>
/// Request for <c>PUT /api/model/selection</c>. <see cref="Role"/> must be
/// "Main", "Fallback", or "Compaction".
/// </summary>
public sealed record PutModelSelectionRequest
{
public required string Role { get; init; }

public required ModelSelectionReference Reference { get; init; }
}

/// <summary>
/// Response for <c>PUT /api/model/selection</c>.
/// </summary>
public sealed record PutModelSelectionResponse
{
public required string ConfigPath { get; init; }

/// <summary>
/// Always true: model selection is bound at daemon startup. The daemon
/// must be restarted before the new selection takes effect.
/// </summary>
public bool RestartRequired { get; init; } = true;
}

/// <summary>
/// Error response for <c>PUT /api/model/selection</c> when the request fails
/// validation or schema checks.
/// </summary>
public sealed record PutModelSelectionErrorResponse
{
public required string Message { get; init; }

public string[] ValidationErrors { get; init; } = [];
}
2 changes: 2 additions & 0 deletions src/Netclaw.Configuration/Netclaw.Configuration.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
CVE-2026-26171 / CVE-2026-33116 (https://github.com/advisories/GHSA-w3x6-4m5h-cxqf). -->
<PackageReference Include="System.Security.Cryptography.Xml" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="NSec.Cryptography" />
<PackageReference Include="SauceControl.Blake2Fast" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,7 @@
"ContextWindow": { "type": "integer", "description": "Effective runtime context window in tokens. When set, clamps the detected provider value." },
"Provenance": {
"type": ["string", "null"],
"enum": ["Live", "Defaults", "Manual", null],
"description": "Optional model provenance marker (Live, Defaults, or Manual)."
},
"InputModalities": {
Expand Down
Loading
Loading