Skip to content

Commit 02bc07a

Browse files
committed
fix(config): auto-pair the configuring client when enabling non-local exposure
Switching to a non-local exposure mode from `netclaw config` could lock the operator out of `netclaw chat`. The config rewrite added a CLI pre-flight gate (GetBootstrapPairingValidationError) that short-circuited the save — and thus the bootstrap auto-pair — whenever any leftover/partial pairing state existed, routing the operator to `netclaw doctor` instead. dev has no such gate: it relies on the wizard/daemon bootstrap seeders, which auto-pair the current client. Replace the block with EnsureCurrentClientPaired: after writing the exposure mode, guarantee the configuring client keeps a working pairing. If the local DeviceToken already matches a device, do nothing; if it is orphaned/mismatched, mint a device that accepts the existing token; if absent (or corrupt/unparseable), mint a fresh token+device. It never removes existing devices, so it only ever ADDS access for the operator at the keyboard — mirroring the wizard's ContributeSecrets and the daemon's BootstrapDeviceSeeder, which only auto-pair on a fully fresh install. - ExposureModeStepViewModel: add EnsureCurrentClientPaired plus device/secret read+write helpers and base64-tolerant hashing; drop GetBootstrapPairingValidationError - ExposureModeConfigViewModel: drop the pre-flight block; pair after WriteConfig - tests: the orphaned/empty-registry/mismatched cases now assert the configuring client is paired rather than blocked
1 parent 6d24d2a commit 02bc07a

3 files changed

Lines changed: 153 additions & 34 deletions

File tree

src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public void Saving_first_non_local_mode_auto_pairs_current_client()
102102
}
103103

104104
[Fact]
105-
public void Saving_non_local_with_orphaned_local_token_blocks_before_persistence()
105+
public void Saving_non_local_with_orphaned_local_token_pairs_current_client()
106106
{
107107
File.WriteAllText(Context.Paths.NetclawConfigPath,
108108
"""
@@ -113,24 +113,34 @@ public void Saving_non_local_with_orphaned_local_token_blocks_before_persistence
113113
}
114114
}
115115
""");
116-
File.WriteAllText(Context.Paths.SecretsPath, "{\"configVersion\":1,\"DeviceToken\":\"orphaned-token\"}");
117-
var configBefore = File.ReadAllText(Context.Paths.NetclawConfigPath);
116+
// A real DeviceToken is always a base64url token; orphaned = present in secrets with no
117+
// matching device in the (absent) registry.
118+
var (orphanedToken, _) = CreatePairedDevice("orphan");
119+
File.WriteAllText(Context.Paths.SecretsPath, JsonSerializer.Serialize(new Dictionary<string, object>
120+
{
121+
["configVersion"] = 1,
122+
["DeviceToken"] = orphanedToken
123+
}));
118124

119125
using var vm = new ExposureModeConfigViewModel(Context.Paths);
120126
vm.Step.SelectedMode = ExposureMode.TailscaleServe;
121127

122128
AdvanceTunnelModeToSave(vm);
123129

124-
Assert.False(vm.IsSaved.Value);
125-
Assert.Contains("netclaw doctor", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase);
126-
Assert.Contains("docs/spec/SPEC-006-gateway-exposure-and-remote-access.md", vm.Context.StatusMessage.Value, StringComparison.Ordinal);
127-
Assert.Contains("#875", vm.Context.StatusMessage.Value, StringComparison.Ordinal);
128-
Assert.Equal(configBefore, File.ReadAllText(Context.Paths.NetclawConfigPath));
129-
Assert.False(File.Exists(Context.Paths.DevicesPath));
130+
// Auto-pair instead of blocking: keep the operator's existing token and mint a device that
131+
// accepts it so the configuring client is not locked out of chat.
132+
Assert.True(vm.IsSaved.Value);
133+
var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath);
134+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode));
135+
Assert.Equal("tailscale-serve", mode);
136+
Assert.Equal(orphanedToken, ReadLocalDeviceToken());
137+
var device = Assert.Single(ReadPairedDevices());
138+
Assert.True(device.IsBootstrapDevice);
139+
Assert.True(PairedDevice.VerifyToken(orphanedToken, device));
130140
}
131141

132142
[Fact]
133-
public void Saving_non_local_with_empty_devices_file_blocks_before_persistence()
143+
public void Saving_non_local_with_empty_devices_file_pairs_current_client()
134144
{
135145
File.WriteAllText(Context.Paths.NetclawConfigPath,
136146
"""
@@ -142,22 +152,26 @@ public void Saving_non_local_with_empty_devices_file_blocks_before_persistence()
142152
}
143153
""");
144154
File.WriteAllText(Context.Paths.DevicesPath, "[]");
145-
var configBefore = File.ReadAllText(Context.Paths.NetclawConfigPath);
146155

147156
using var vm = new ExposureModeConfigViewModel(Context.Paths);
148157
vm.Step.SelectedMode = ExposureMode.TailscaleServe;
149158

150159
AdvanceTunnelModeToSave(vm);
151160

152-
Assert.False(vm.IsSaved.Value);
153-
Assert.Contains("netclaw doctor", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase);
154-
Assert.Contains("#875", vm.Context.StatusMessage.Value, StringComparison.Ordinal);
155-
Assert.Equal(configBefore, File.ReadAllText(Context.Paths.NetclawConfigPath));
156-
Assert.Equal("[]", File.ReadAllText(Context.Paths.DevicesPath));
161+
// No token and an empty registry: mint a fresh token+device for the configuring client.
162+
Assert.True(vm.IsSaved.Value);
163+
var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath);
164+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode));
165+
Assert.Equal("tailscale-serve", mode);
166+
var rawToken = ReadLocalDeviceToken();
167+
Assert.False(string.IsNullOrWhiteSpace(rawToken));
168+
var device = Assert.Single(ReadPairedDevices());
169+
Assert.True(device.IsBootstrapDevice);
170+
Assert.True(PairedDevice.VerifyToken(rawToken, device));
157171
}
158172

159173
[Fact]
160-
public void Saving_non_local_with_mismatched_local_token_blocks_before_persistence()
174+
public void Saving_non_local_with_mismatched_local_token_pairs_current_client()
161175
{
162176
File.WriteAllText(Context.Paths.NetclawConfigPath,
163177
"""
@@ -176,17 +190,23 @@ public void Saving_non_local_with_mismatched_local_token_blocks_before_persisten
176190
["configVersion"] = 1,
177191
["DeviceToken"] = mismatchedToken
178192
}));
179-
var configBefore = File.ReadAllText(Context.Paths.NetclawConfigPath);
180193

181194
using var vm = new ExposureModeConfigViewModel(Context.Paths);
182195
vm.Step.SelectedMode = ExposureMode.TailscaleServe;
183196

184197
AdvanceTunnelModeToSave(vm);
185198

186-
Assert.False(vm.IsSaved.Value);
187-
Assert.Contains("Bootstrap pairing state", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase);
188-
Assert.Contains("#875", vm.Context.StatusMessage.Value, StringComparison.Ordinal);
189-
Assert.Equal(configBefore, File.ReadAllText(Context.Paths.NetclawConfigPath));
199+
// The local token matches no registered device: mint an additional device that accepts it
200+
// without removing the pre-existing one, so the configuring client retains access.
201+
Assert.True(vm.IsSaved.Value);
202+
var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath);
203+
Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode));
204+
Assert.Equal("tailscale-serve", mode);
205+
Assert.Equal(mismatchedToken, ReadLocalDeviceToken());
206+
var devices = ReadPairedDevices();
207+
Assert.Equal(2, devices.Count);
208+
Assert.Contains(devices, d => PairedDevice.VerifyToken(mismatchedToken, d));
209+
Assert.Contains(devices, d => d.Name == registeredDevice.Name);
190210
}
191211

192212
[Fact]

src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,13 @@ public void GoNext()
6262
return;
6363
}
6464

65-
if (_step.GetBootstrapPairingValidationError(_context.Paths) is { } pairingError)
66-
{
67-
_context.StatusMessage.Value = pairingError;
68-
NotifyContentChanged();
69-
return;
70-
}
71-
7265
_orchestrator.WriteConfig();
66+
67+
// Keep the configuring client authenticated after switching to a non-local mode. WriteConfig
68+
// already auto-pairs a fully fresh install (the wizard bootstrap path); this also covers
69+
// leftover/partial pairing state so `netclaw config` never locks the operator out of chat.
70+
_step.EnsureCurrentClientPaired(_context.Paths);
71+
7372
IsSaved.Value = true;
7473
_context.StatusMessage.Value = "Exposure mode saved.";
7574
NotifyContentChanged();

src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -305,19 +305,119 @@ public IWizardStepViewModel CreateEditor(IServiceProvider services)
305305
: null;
306306
}
307307

308-
internal string? GetBootstrapPairingValidationError(NetclawPaths paths)
308+
/// <summary>
309+
/// Guarantees the operator's current client keeps daemon access after a non-local exposure
310+
/// mode is saved. If the local <c>DeviceToken</c> does not already match a paired device, the
311+
/// configuring client is paired: an existing-but-unmatched token (orphaned or mismatched local
312+
/// state) gets a device minted to accept it; a missing token gets a fresh token+device. Existing
313+
/// devices are never removed, so this only ever ADDS access for the operator at the keyboard.
314+
///
315+
/// This replaces an earlier hard "fix pairing via `netclaw doctor` before saving" block: that
316+
/// block locked the configuring client out of <c>netclaw chat</c> on any leftover/partial pairing
317+
/// state. Auto-pairing here mirrors the wizard's bootstrap (<see cref="ContributeSecrets"/>) and
318+
/// the daemon's <c>BootstrapDeviceSeeder</c>, which only auto-pair on a fully fresh install.
319+
/// </summary>
320+
public void EnsureCurrentClientPaired(NetclawPaths paths)
309321
{
310322
if (!SelectedMode.RequiresRemoteAuthentication())
311-
return null;
323+
return;
312324

313325
var snapshot = DeviceRegistryInspector.Read(paths);
314-
if (!snapshot.DevicesFileExists && !snapshot.HasLocalDeviceToken && !snapshot.HasCompletedBootstrap)
326+
if (snapshot.LocalTokenMatchesDevice)
327+
return; // The configuring client already has a working pairing — nothing to do.
328+
329+
var saltHex = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
330+
331+
// Keep the operator's existing local token when one is present and usable (orphaned/
332+
// mismatched) so an already-distributed token keeps working; otherwise — including a
333+
// corrupted/unparseable token — mint a fresh one for this client rather than crash the save.
334+
var rawToken = snapshot.HasLocalDeviceToken ? ReadLocalDeviceTokenValue(paths) : null;
335+
if (string.IsNullOrWhiteSpace(rawToken) || !TryComputeTokenHash(rawToken, saltHex, out var tokenHash))
336+
{
337+
rawToken = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
338+
WriteLocalDeviceTokenValue(paths, rawToken);
339+
tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex);
340+
}
341+
342+
var now = _timeProvider.GetUtcNow();
343+
var device = new PairedDevice
344+
{
345+
Name = Environment.MachineName,
346+
IsBootstrapDevice = true,
347+
TokenHash = tokenHash,
348+
Salt = saltHex,
349+
CreatedAt = now,
350+
LastUsedAt = now,
351+
};
352+
353+
var devices = ReadPairedDevices(paths);
354+
devices.Add(device);
355+
WritePairedDevices(paths, devices);
356+
}
357+
358+
private static string? ReadLocalDeviceTokenValue(NetclawPaths paths)
359+
{
360+
if (!File.Exists(paths.SecretsPath))
315361
return null;
316362

317-
if (snapshot.DeviceCount > 0 && snapshot.LocalTokenMatchesDevice)
363+
var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath);
364+
if (!secrets.TryGetValue("DeviceToken", out var rawValue))
318365
return null;
319366

320-
return "Bootstrap pairing state is incomplete or mismatched. Run 'netclaw doctor', review docs/spec/SPEC-006-gateway-exposure-and-remote-access.md, and see issue #875 before saving non-local exposure.";
367+
var token = rawValue is JsonElement jsonElement ? jsonElement.GetString() : rawValue?.ToString();
368+
return ConfigFileHelper.DecryptIfEncrypted(paths, token);
369+
}
370+
371+
private static void WriteLocalDeviceTokenValue(NetclawPaths paths, string rawToken)
372+
{
373+
var secrets = File.Exists(paths.SecretsPath)
374+
? ConfigFileHelper.LoadJsonDict(paths.SecretsPath)
375+
: new Dictionary<string, object>();
376+
secrets["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion;
377+
secrets["DeviceToken"] = rawToken;
378+
ConfigFileHelper.WriteSecretsFile(paths, secrets);
379+
}
380+
381+
private static List<PairedDevice> ReadPairedDevices(NetclawPaths paths)
382+
{
383+
if (!File.Exists(paths.DevicesPath))
384+
return [];
385+
386+
try
387+
{
388+
using var doc = JsonDocument.Parse(File.ReadAllText(paths.DevicesPath));
389+
return doc.RootElement.ValueKind == JsonValueKind.Array
390+
? JsonSerializer.Deserialize<List<PairedDevice>>(doc.RootElement.GetRawText(), DevicesJsonOptions) ?? []
391+
: [];
392+
}
393+
catch (JsonException)
394+
{
395+
return [];
396+
}
397+
}
398+
399+
private static void WritePairedDevices(NetclawPaths paths, IReadOnlyList<PairedDevice> devices)
400+
{
401+
var json = JsonSerializer.Serialize(devices, DevicesJsonOptions);
402+
File.WriteAllText(paths.DevicesPath, json);
403+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && File.Exists(paths.DevicesPath))
404+
File.SetUnixFileMode(paths.DevicesPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
405+
}
406+
407+
private static bool TryComputeTokenHash(string rawToken, string saltHex, out string tokenHash)
408+
{
409+
try
410+
{
411+
tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex);
412+
return true;
413+
}
414+
catch (FormatException)
415+
{
416+
// A corrupted/non-base64url local token cannot produce a usable device hash; signal the
417+
// caller to mint a fresh token instead of letting the save crash.
418+
tokenHash = string.Empty;
419+
return false;
420+
}
321421
}
322422

323423
public SectionContribution BuildContribution(IWizardStepViewModel editor)

0 commit comments

Comments
 (0)