Skip to content

Commit 2df3564

Browse files
fix: guard bootstrap prompt against already-configured workspaces (#729)
Root cause: OnboardingChatBootstrapper.BootstrapAsync() only checked the HasInjectedFirstRunBootstrap flag, which could be unset (false) even when the user already had a configured gateway (e.g. fresh app install over an existing workspace, settings migration, or flag reset). This caused the first-run ritual prompt to fire against an already-configured workspace, risking unintended rewrites of SOUL.md, AGENTS.md, and other workspace files. Fix: Add an optional GatewayRegistry parameter to BootstrapAsync(). Before sending the bootstrap message, check SetupExistingGatewayClassifier .HasAnyExistingGatewayConnection(). If an existing gateway configuration is detected, silently mark the gate as consumed and return true without sending the prompt. ChatPage.xaml.cs now passes app.Registry to BootstrapAsync() to enable this guard. Tests: 4 new unit tests cover the existing-gateway-skip path (SharedGatewayToken, BootstrapToken), the empty-registry first-run path, and the no-registry path (backward compatibility). All 996 tray tests pass. Closes #729 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0e61fa2 commit 2df3564

3 files changed

Lines changed: 109 additions & 4 deletions

File tree

src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -496,12 +496,14 @@ private async Task NavigateWhenChatReadyAsync(
496496
}
497497

498498
WaitingStatusText.Text = LocalizationHelper.GetString("ChatPage_ChatReady");
499+
var app = (App)Application.Current;
499500
var bootstrapped = await OnboardingChatBootstrapper.BootstrapAsync(
500501
connectionManager?.OperatorClient,
501-
((App)Application.Current).Settings,
502+
app.Settings,
502503
TimeSpan.FromSeconds(90),
503-
cancellationToken).ConfigureAwait(true);
504-
if (!bootstrapped && !((App)Application.Current).Settings.HasInjectedFirstRunBootstrap)
504+
cancellationToken,
505+
registry: app.Registry).ConfigureAwait(true);
506+
if (!bootstrapped && !app.Settings.HasInjectedFirstRunBootstrap)
505507
{
506508
Logger.Warn("[ChatPage] Gateway hatching bootstrap did not complete; navigating to empty chat");
507509
}

src/OpenClaw.Tray.WinUI/Services/OnboardingChatBootstrapper.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using OpenClaw.Connection;
12
using OpenClaw.Shared;
23
using System;
34
using System.Collections.Generic;
@@ -36,12 +37,27 @@ public static async Task<bool> BootstrapAsync(
3637
IOperatorGatewayClient? client,
3738
SettingsManager settings,
3839
TimeSpan? completionTimeout = null,
39-
CancellationToken cancellationToken = default)
40+
CancellationToken cancellationToken = default,
41+
GatewayRegistry? registry = null)
4042
{
4143
ArgumentNullException.ThrowIfNull(settings);
4244

4345
if (settings.HasInjectedFirstRunBootstrap)
4446
return true;
47+
48+
// Guard: if the user already has a configured gateway, treat this as a non-first-run
49+
// installation and silently consume the bootstrap gate without sending the prompt.
50+
// This prevents the first-run ritual from firing against an already-configured workspace
51+
// in cases where the HasInjectedFirstRunBootstrap flag was not persisted (e.g. fresh
52+
// app install over an existing workspace, settings migration, or flag reset).
53+
if (registry is not null && SetupExistingGatewayClassifier.HasAnyExistingGatewayConnection(
54+
registry, settings, SettingsManager.SettingsDirectoryPath))
55+
{
56+
MarkBootstrapped(settings);
57+
Logger.Info("[OnboardingChatBootstrapper] Existing gateway configuration detected; skipping first-run bootstrap prompt.");
58+
return true;
59+
}
60+
4561
if (client == null || !client.IsConnectedToGateway)
4662
return false;
4763
if (Interlocked.CompareExchange(ref s_inFlight, 1, 0) != 0)

tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using OpenClaw.Connection;
12
using OpenClaw.Shared;
23
using OpenClawTray.Services;
34
using System.Text.Json;
@@ -82,6 +83,92 @@ public async Task BootstrapAsync_DoesNotConsumeGate_WhenCompletionTimesOut()
8283
Assert.False(settings.HasInjectedFirstRunBootstrap);
8384
}
8485

86+
[Fact]
87+
public async Task BootstrapAsync_SkipsPromptAndMarksBootstrapped_WhenRegistryHasExistingGatewayWithSharedToken()
88+
{
89+
var settings = new SettingsManager(_settingsDir);
90+
var client = new FakeOperatorGatewayClient { IsConnectedToGateway = true };
91+
92+
var registryDir = Path.Combine(_settingsDir, "registry-existing");
93+
Directory.CreateDirectory(registryDir);
94+
var registry = new GatewayRegistry(registryDir);
95+
registry.AddOrUpdate(new GatewayRecord
96+
{
97+
Id = "gw-existing",
98+
Url = "ws://192.168.1.10:18789",
99+
SharedGatewayToken = "existing-shared-token"
100+
});
101+
102+
var result = await OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5), registry: registry);
103+
104+
Assert.True(result, "Should return true when existing gateway is detected.");
105+
Assert.Equal(0, client.SendCount);
106+
Assert.True(settings.HasInjectedFirstRunBootstrap, "Gate should be marked so the check doesn't repeat.");
107+
}
108+
109+
[Fact]
110+
public async Task BootstrapAsync_SkipsPromptAndMarksBootstrapped_WhenRegistryHasExistingGatewayWithBootstrapToken()
111+
{
112+
var settings = new SettingsManager(_settingsDir);
113+
var client = new FakeOperatorGatewayClient { IsConnectedToGateway = true };
114+
115+
var registryDir = Path.Combine(_settingsDir, "registry-bootstrap");
116+
Directory.CreateDirectory(registryDir);
117+
var registry = new GatewayRegistry(registryDir);
118+
registry.AddOrUpdate(new GatewayRecord
119+
{
120+
Id = "gw-bootstrap",
121+
Url = "ws://my-gateway:18789",
122+
BootstrapToken = "existing-bootstrap-token"
123+
});
124+
125+
var result = await OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5), registry: registry);
126+
127+
Assert.True(result);
128+
Assert.Equal(0, client.SendCount);
129+
Assert.True(settings.HasInjectedFirstRunBootstrap);
130+
}
131+
132+
[Fact]
133+
public async Task BootstrapAsync_SendsBootstrapPrompt_WhenRegistryIsEmptyAndGatewayIsNew()
134+
{
135+
var settings = new SettingsManager(_settingsDir);
136+
var client = new FakeOperatorGatewayClient { Result = new ChatSendResult { RunId = "run-new" } };
137+
138+
var registryDir = Path.Combine(_settingsDir, "registry-empty");
139+
Directory.CreateDirectory(registryDir);
140+
var registry = new GatewayRegistry(registryDir);
141+
// Registry has no records — this is a true first-run scenario.
142+
143+
var task = OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5), registry: registry);
144+
// slopwatch-ignore: SW004 Test delay is an intentional bounded async wait; replacing it would change the scenario under test.
145+
await Task.Delay(50);
146+
client.RaiseFinalAssistant("run-new");
147+
var result = await task;
148+
149+
Assert.True(result);
150+
Assert.Equal(1, client.SendCount);
151+
Assert.Equal(OnboardingChatBootstrapper.Message, client.LastMessage);
152+
Assert.True(settings.HasInjectedFirstRunBootstrap);
153+
}
154+
155+
[Fact]
156+
public async Task BootstrapAsync_SendsBootstrapPrompt_WhenNoRegistryProvided()
157+
{
158+
var settings = new SettingsManager(_settingsDir);
159+
var client = new FakeOperatorGatewayClient { Result = new ChatSendResult { RunId = "run-noregistry" } };
160+
161+
var task = OnboardingChatBootstrapper.BootstrapAsync(client, settings, TimeSpan.FromSeconds(5));
162+
// slopwatch-ignore: SW004 Test delay is an intentional bounded async wait; replacing it would change the scenario under test.
163+
await Task.Delay(50);
164+
client.RaiseFinalAssistant("run-noregistry");
165+
var result = await task;
166+
167+
Assert.True(result);
168+
Assert.Equal(1, client.SendCount);
169+
Assert.True(settings.HasInjectedFirstRunBootstrap);
170+
}
171+
85172
#pragma warning disable CS0067
86173
private sealed class FakeOperatorGatewayClient : IOperatorGatewayClient
87174
{

0 commit comments

Comments
 (0)