Skip to content

Commit 62a4e77

Browse files
Guard first-run bootstrap for existing workspaces
Fail closed when an existing gateway is configured but workspace-state probing is inconclusive, so first-run bootstrap does not overwrite an established workspace. Use the active SettingsManager directory for legacy identity detection and add regression coverage for timeout/failure paths.
1 parent 2153c7e commit 62a4e77

4 files changed

Lines changed: 492 additions & 5 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: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
using OpenClaw.Connection;
12
using OpenClaw.Shared;
23
using System;
34
using System.Collections.Generic;
5+
using System.IO;
46
using System.Text.Json;
57
using System.Threading;
68
using System.Threading.Tasks;
@@ -10,6 +12,22 @@ namespace OpenClawTray.Services;
1012
public static class OnboardingChatBootstrapper
1113
{
1214
private static int s_inFlight;
15+
private static readonly TimeSpan ExistingWorkspaceProbeTimeout = TimeSpan.FromSeconds(3);
16+
private static readonly HashSet<string> ExistingWorkspaceMarkerFiles = new(StringComparer.Ordinal)
17+
{
18+
"SOUL.md",
19+
"IDENTITY.md",
20+
"USER.md",
21+
"HEARTBEAT.md",
22+
"MEMORY.md",
23+
};
24+
25+
private enum ExistingWorkspaceState
26+
{
27+
Unknown,
28+
Empty,
29+
Existing,
30+
}
1331

1432
public const string Message =
1533
"Hi! I just installed OpenClaw and you're my brand-new agent. " +
@@ -36,14 +54,47 @@ public static async Task<bool> BootstrapAsync(
3654
IOperatorGatewayClient? client,
3755
SettingsManager settings,
3856
TimeSpan? completionTimeout = null,
39-
CancellationToken cancellationToken = default)
57+
CancellationToken cancellationToken = default,
58+
GatewayRegistry? registry = null,
59+
TimeSpan? existingWorkspaceProbeTimeout = null)
4060
{
4161
ArgumentNullException.ThrowIfNull(settings);
4262

4363
if (settings.HasInjectedFirstRunBootstrap)
4464
return true;
65+
4566
if (client == null || !client.IsConnectedToGateway)
4667
return false;
68+
69+
// A saved gateway credential is not enough to suppress hatching: fresh local setup
70+
// creates registry-backed credentials before the first-run prompt has been sent.
71+
// Only consume the gate when the connected workspace already contains durable
72+
// OpenClaw state that the bootstrap ritual would otherwise rewrite.
73+
if (registry is not null &&
74+
SetupExistingGatewayClassifier.HasAnyExistingGatewayConnection(
75+
registry,
76+
settings,
77+
settings.SettingsDirectory))
78+
{
79+
var workspaceState = await ProbeExistingWorkspaceStateAsync(
80+
client,
81+
existingWorkspaceProbeTimeout ?? ExistingWorkspaceProbeTimeout,
82+
cancellationToken).ConfigureAwait(true);
83+
84+
if (workspaceState == ExistingWorkspaceState.Existing)
85+
{
86+
MarkBootstrapped(settings);
87+
Logger.Info("[OnboardingChatBootstrapper] Existing OpenClaw workspace state detected; skipping first-run bootstrap prompt.");
88+
return true;
89+
}
90+
91+
if (workspaceState == ExistingWorkspaceState.Unknown)
92+
{
93+
Logger.Warn("[OnboardingChatBootstrapper] Workspace state probe was unavailable; not sending first-run bootstrap automatically.");
94+
return false;
95+
}
96+
}
97+
4798
if (Interlocked.CompareExchange(ref s_inFlight, 1, 0) != 0)
4899
{
49100
Logger.Info("[OnboardingChatBootstrapper] Bootstrap skipped because another gateway send is in flight");
@@ -94,6 +145,122 @@ public static async Task<bool> BootstrapAsync(
94145
}
95146
}
96147

148+
private static async Task<ExistingWorkspaceState> ProbeExistingWorkspaceStateAsync(
149+
IOperatorGatewayClient client,
150+
TimeSpan timeout,
151+
CancellationToken cancellationToken)
152+
{
153+
const string agentId = "main";
154+
using var observer = new AgentFilesListObserver(client, agentId);
155+
try
156+
{
157+
await client.RequestAgentFilesListAsync(agentId).ConfigureAwait(true);
158+
}
159+
catch (OperationCanceledException)
160+
{
161+
throw;
162+
}
163+
catch (Exception ex)
164+
{
165+
Logger.Warn($"[OnboardingChatBootstrapper] Workspace state probe failed: {ex.Message}");
166+
return ExistingWorkspaceState.Unknown;
167+
}
168+
169+
var payload = await observer.WaitForFilesListAsync(
170+
DateTimeOffset.UtcNow + timeout,
171+
cancellationToken).ConfigureAwait(true);
172+
173+
if (payload is null)
174+
{
175+
Logger.Warn("[OnboardingChatBootstrapper] Workspace state probe returned no file list.");
176+
return ExistingWorkspaceState.Unknown;
177+
}
178+
179+
return ContainsExistingWorkspaceMarker(payload.Value)
180+
? ExistingWorkspaceState.Existing
181+
: ExistingWorkspaceState.Empty;
182+
}
183+
184+
private static bool ContainsExistingWorkspaceMarker(JsonElement payload)
185+
{
186+
if (!payload.TryGetProperty("files", out var filesEl) || filesEl.ValueKind != JsonValueKind.Array)
187+
return false;
188+
189+
foreach (var fileEl in filesEl.EnumerateArray())
190+
{
191+
var exists = !fileEl.TryGetProperty("exists", out var existsEl) ||
192+
existsEl.ValueKind != JsonValueKind.False;
193+
if (!exists)
194+
continue;
195+
196+
if (!fileEl.TryGetProperty("name", out var nameEl))
197+
continue;
198+
199+
var name = nameEl.GetString();
200+
if (string.IsNullOrWhiteSpace(name))
201+
continue;
202+
203+
if (ExistingWorkspaceMarkerFiles.Contains(Path.GetFileName(name)))
204+
return true;
205+
}
206+
207+
return false;
208+
}
209+
210+
private sealed class AgentFilesListObserver : IDisposable
211+
{
212+
private readonly IOperatorGatewayClient _client;
213+
private readonly string _agentId;
214+
private readonly TaskCompletionSource<JsonElement?> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
215+
216+
public AgentFilesListObserver(IOperatorGatewayClient client, string agentId)
217+
{
218+
_client = client;
219+
_agentId = agentId;
220+
_client.AgentFilesListUpdated += OnAgentFilesListUpdated;
221+
}
222+
223+
public async Task<JsonElement?> WaitForFilesListAsync(
224+
DateTimeOffset timeoutAt,
225+
CancellationToken cancellationToken)
226+
{
227+
if (_completion.Task.IsCompleted)
228+
return await _completion.Task.ConfigureAwait(true);
229+
230+
var remaining = timeoutAt - DateTimeOffset.UtcNow;
231+
if (remaining <= TimeSpan.Zero)
232+
return null;
233+
234+
var completed = await Task.WhenAny(_completion.Task, Task.Delay(remaining, cancellationToken)).ConfigureAwait(true);
235+
if (completed != _completion.Task)
236+
{
237+
cancellationToken.ThrowIfCancellationRequested();
238+
return null;
239+
}
240+
241+
return await _completion.Task.ConfigureAwait(true);
242+
}
243+
244+
public void Dispose()
245+
{
246+
_client.AgentFilesListUpdated -= OnAgentFilesListUpdated;
247+
}
248+
249+
private void OnAgentFilesListUpdated(object? sender, JsonElement payload)
250+
{
251+
if (sender != _client)
252+
return;
253+
254+
if (payload.TryGetProperty("agentId", out var agentIdEl) &&
255+
!string.Equals(agentIdEl.GetString(), _agentId, StringComparison.OrdinalIgnoreCase))
256+
{
257+
return;
258+
}
259+
260+
_completion.TrySetResult(payload.Clone());
261+
}
262+
}
263+
97264
private sealed class RunCompletionObserver : IDisposable
98265
{
99266
private readonly IOperatorGatewayClient _client;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class SettingsManager
2222

2323
public static string SettingsDirectoryPath => GetDefaultSettingsDirectory();
2424
public static string SettingsPath => Path.Combine(SettingsDirectoryPath, "settings.json");
25+
public string SettingsDirectory => _settingsDirectory;
2526

2627
/// <summary>Raised after settings are persisted to disk.</summary>
2728
public event EventHandler? Saved;

0 commit comments

Comments
 (0)