diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b27138f1..89c73d533 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,6 +39,10 @@ multi-PR refactor plan for the reasoning behind each boundary. execution, WebSocket/MCP hosting. No UI types. No background work started from constructors. - **App** (`App.xaml.cs`): composition root and top-level lifecycle only. +- **Shared mutable domains**: one observable service/store owns each persisted + domain. View models consume snapshots and field-scoped or compare-and-swap + mutations; they never own backing files, concrete managers, file observers, or + parallel mutable caches. ## Single-source owners @@ -57,10 +61,13 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | UI-thread marshaling for presentation code | `IUiDispatcher` | authoritative | | Page view-model activation/deactivation + disposal lifetime | `NavigationScopeManager` | authoritative | | Presentation-layer DI composition root | `AppServiceRegistration` (root `ServiceProvider`, owned by `App`) | authoritative | -| Settings snapshot read + batched save + non-echoing change notification | `ISettingsStore` | authoritative | +| Settings snapshot read + field-scoped save + origin-aware change notification | `ISettingsStore` | authoritative | +| V2 exec-approvals snapshot/CAS persistence + observation | `ExecApprovalsStore` through `IExecApprovalsPresentationStore` | authoritative | | Settings page load/persist view logic | `SettingsPageViewModel` | authoritative | | Managed-local listener provenance and strong-credential authorization | `ManagedLocalGatewayPortProvenanceService` | authoritative | | Managed-local automatic repair eligibility and orchestration | `ManagedLocalGatewayAutoRepairMonitor` + `ManagedLocalGatewayRepairCoordinator` | authoritative | +| Permissions page state, settings commands, and exec-approvals presentation | `PermissionsPageViewModel` | authoritative | +| Permissions runtime status projection | `PermissionsPageRuntimeSource` | authoritative | | Capability UI metadata | `NodeCapabilityUiCatalog` (planned) | planned | | Capability registration/gating | `NodeCapabilityRegistrationPolicy` (planned) | planned | | Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned | @@ -77,6 +84,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` | | `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, gateway row models | | `src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs` | settings read/persist → `SettingsPageViewModel` + `ISettingsStore`; keep gateway-uninstall, uptime timer, saved-indicator, and app-info in the view | +| `src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs` | state/commands → `PermissionsPageViewModel`; runtime projection → `PermissionsPageRuntimeSource`; persistence → `ISettingsStore` and `IExecApprovalsPresentationStore`; keep exact WinUI rendering, clipboard/privacy actions, and save-hint timer in the view | | `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` | `McpServerHost`, `CanvasWindowManager`, `MediaCapabilityHost`, `RecordingConsentService`, `NodeCapabilityRegistry` | | `src/OpenClaw.Shared/OpenClawGatewayClient.cs` | `PendingRequestRegistry`, `ConnectEnvelopeBuilder`, `GatewayMessageRouter`, per-domain API facades | | `src/OpenClaw.Shared/Models.cs` | per-domain model files + `*Mapper` classes | @@ -134,8 +142,12 @@ leading and trailing pipe. Columns, in order: | node-summary-text | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | node-summary clipboard text formatting | NodeSummaryText | App keeps the clipboard side effect (building the DataPackage and setting clipboard content) | copied node-summary text is projected only by NodeSummaryText.Build (online/offline state, display-name fallback, short id, detail text, newline join) | NodeSummaryTextTests.Build_MultipleNodes_OneLinePerNodeJoinedByNewline | behavioral | - | | reactor-chat-timeline | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | production chat message virtualization, row realization, and imperative scroll follow | ReactorChatTimeline through OpenClawReactorChatRoot and ReactorHostControl | OpenClawChatTimeline remains a legacy focused-test surface while its runtime route is migrated | the default chat route mounts one direct ReactorHostControl per XAML chat target; Reactor owns stable-key ItemsView and ItemContainer realization without a custom native list, collection reconciler, or scroll-layout mutation | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when Reactor timeline proof coverage replaces the legacy focused UI host coverage | | functional-chat-default-mount | closed | src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs | mounting the FunctionalUI chat tree as the default ChatPage or ChatWindow surface | ReactorChatHostExtensions and OpenClawReactorChatRoot | legacy FunctionalUI chat files may remain for focused compatibility coverage only | ChatPage and ChatWindow mount the Reactor root directly into their existing ChatHost Borders; no FunctionalUI component mounts or nests Reactor on the default path | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when legacy FunctionalUI chat surfaces are removed | -| settings-store | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | hand-rolled save/echo suppression flags for two-way settings binding | ISettingsStore | PermissionsPage and other surfaces may read SettingsManager directly until migrated | a save originating from Update does not echo Changed to the caller and external saves are republished on the UI thread | SettingsStoreTests.Update_DoesNotEchoChangedToSelf | behavioral | when all settings surfaces read and write through ISettingsStore | +| settings-store | authoritative | settings and permission UI surfaces | direct SettingsManager mutation and blanket self-write suppression | ISettingsStore | non-permission legacy surfaces may read SettingsManager until migrated; direct saves publish origin null | every save publishes one versioned event; only the matching writer ignores its own origin while all other active consumers refresh | SettingsSharedStateContractTests.TwoActiveSettingsPageViewModels_IgnoreOnlyOwnWrites_InBothDirections | behavioral | when every settings surface reads and writes through ISettingsStore | | settings-page-vm | authoritative | src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | settings load, persist, echo-guard, and auto-save wiring | SettingsPageViewModel | code-behind keeps gateway-uninstall, gateway-info and uptime timer, saved-indicator visual, and app-info population | each settings control persists its field through the store preserving mutate-save-notify order and does not re-persist on external change | SettingsPageViewModelTests.ExternalChange_ReloadsWithoutRePersisting | behavioral | when the Settings page holds no settings persistence logic in code-behind | +| exec-approvals-store | authoritative | src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs | direct exec-approvals.json snapshot, CAS persistence, file observation, and mutable policy cache | ExecApprovalsStore through IExecApprovalsPresentationStore | SystemCapability and NodeService consume the same App-owned concrete store for runtime enforcement | pure reads create nothing; CAS rejects stale hashes; one store-owned observer publishes each distinct external replacement once and retains the last valid presentation snapshot on typed failure | ExecApprovalsStoreTests.Changed_ExternalCorruptThenValid_RaisesFailureThenRecovery | behavioral | - | +| permissions-page-vm | authoritative | src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs | permission settings state, exec-approvals mutations, node/MCP/voice status decisions, and lifecycle subscriptions | PermissionsPageViewModel plus PermissionsPageRuntimeSource | code-behind keeps exact WinUI row/card construction, localization application, colors, visibility, clipboard/token reads, privacy launch, and save-hint timer | activation is pure; field-scoped settings writes preserve save-then-notify; V2 mutations preserve unrelated fields through CAS retry; deactivate/dispose releases subscriptions | PermissionsPageViewModelTests.ExternalValidChange_UpdatesOnce_AndCorruptRetainsLastValidDisplay | behavioral | - | +| permissions-page-direct-owners-closed | closed | src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs | direct SettingsManager, ConnectionManager, and ExecApprovalsStore ownership or subscriptions | PermissionsPageViewModel plus authoritative stores | WinUI-only rendering and platform actions listed in permissions-page-vm | the page applies semantic state only; the view model is WinUI/App/SettingsManager/file-IO free and never creates a parallel mutable domain cache | PermissionsPageContractTests.PermissionsPageViewModel_StaysWinUiAndAppFree | source-shape | when PermissionsPage is replaced by a different view technology | +| shared-mutable-domain-owner | authoritative | presentation pages and view models | backing-file or concrete-manager ownership, per-VM observers, and independent mutable copies of persisted domains | one observable service/store per shared mutable domain | immutable view state projected from authoritative snapshots | different active consumers converge through versioned origin-aware events or CAS snapshots without echo storms, stale whole-snapshot replay, or lost unrelated updates | SettingsSharedStateContractTests.PermissionsPageViewModel_ReceivesOneExternalUpdate_PerDistinctAppSurfaceOrigin | behavioral | - | ## Deferred test builders diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsPresentationContracts.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsPresentationContracts.cs new file mode 100644 index 000000000..09df7965b --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsPresentationContracts.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.ExecApprovals; + +public interface IExecApprovalsPresentationStore +{ + event EventHandler? Changed; + + Task GetSnapshotReadOnlyAsync(CancellationToken cancellationToken = default); + + ExecApprovalsWriterOrigin CreateWriterOrigin(); + + Task ReplaceAsync( + string baseHash, + ExecApprovalsFile replacement, + ExecApprovalsWriterOrigin? origin, + Func? deltaValidator = null); +} + +public sealed class ExecApprovalsWriterOrigin +{ + internal ExecApprovalsWriterOrigin() + { + } +} + +public enum ExecApprovalsChangeKind +{ + SnapshotUpdated, + SnapshotRecovered, + SnapshotInvalid, +} + +public enum ExecApprovalsSnapshotFailureKind +{ + LegacyMigrationRequired, + UntrustedPath, + UnsupportedVersion, + MalformedJson, + ReadFailed, +} + +public sealed record ExecApprovalsSnapshotFailure( + ExecApprovalsSnapshotFailureKind Kind, + string Hash, + int? Version, + string Message); + +public sealed record ExecApprovalsReadOnlySnapshotResult( + ExecApprovalsSnapshot? Snapshot, + ExecApprovalsSnapshotFailure? Failure, + ExecApprovalsSnapshot? LastValidSnapshot) +{ + public bool IsSuccess => Failure is null; +} + +public sealed class ExecApprovalsChangedEventArgs : EventArgs +{ + public ExecApprovalsChangedEventArgs( + long sequence, + ExecApprovalsChangeKind kind, + string hash, + int? version, + ExecApprovalsSnapshot? snapshot, + ExecApprovalsSnapshotFailure? failure, + ExecApprovalsSnapshot? lastValidSnapshot, + ExecApprovalsWriterOrigin? origin) + { + Sequence = sequence; + Kind = kind; + Hash = hash; + Version = version; + Snapshot = snapshot; + Failure = failure; + LastValidSnapshot = lastValidSnapshot; + Origin = origin; + } + + public long Sequence { get; } + + public ExecApprovalsChangeKind Kind { get; } + + public string Hash { get; } + + public int? Version { get; } + + public ExecApprovalsSnapshot? Snapshot { get; } + + public ExecApprovalsSnapshotFailure? Failure { get; } + + public ExecApprovalsSnapshot? LastValidSnapshot { get; } + + public ExecApprovalsWriterOrigin? Origin { get; } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs index 84f4e5fcf..3cac47c5f 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs @@ -4,19 +4,15 @@ using System.Linq; using System.Security.Cryptography; using System.Text; -using System.Threading; -using System.Threading.Tasks; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; namespace OpenClaw.Shared.ExecApprovals; -// Authoritative store for exec-approvals.json. -// Read path: ResolveReadOnly, LoadFile, EnsureFileAsync. Write path: ReplaceAsync, AddAllowlistEntryAsync, RecordAllowlistUseAsync. -public sealed class ExecApprovalsStore +public sealed class ExecApprovalsStore : IExecApprovalsPresentationStore, IDisposable { - // KebabCaseLower covers all macOS enum values: deny, allowlist, full, off, on-miss, always, - // allow-once, allow-always. CamelCase would fail for "on-miss" and "allow-once". internal static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -34,6 +30,17 @@ public sealed class ExecApprovalsStore private readonly string? _legacyFilePath; private readonly IOpenClawLogger _logger; private readonly SemaphoreSlim _lock = new(1, 1); + private readonly SemaphoreSlim _observeLock = new(1, 1); + private readonly object _changeGate = new(); + + private EventHandler? _changed; + private FileSystemWatcher? _watcher; + private string? _watcherObservedPath; + private ExecApprovalsSnapshot? _lastValidPresentationSnapshot; + private string? _lastObservedSignature; + private bool _lastObservedWasFailure; + private long _changeSequence; + private bool _disposed; private enum LegacyMigrationStatus { @@ -46,13 +53,30 @@ private enum LoadFileStatus { Missing, Loaded, - Invalid, + UntrustedPath, + UnsupportedVersion, + MalformedJson, + ReadFailed, } private readonly record struct LoadFileResult( LoadFileStatus Status, ExecApprovalsFile? File, - string? Hash); + string Hash, + int? Version, + string Message) + { + public bool IsInvalid => Status is not LoadFileStatus.Missing and not LoadFileStatus.Loaded; + } + + private readonly record struct EnsureFileResult( + ExecApprovalsFile File, + ExecApprovalsSnapshot? PersistedSnapshot); + + private readonly record struct ReadOnlySnapshotLoadResult( + ExecApprovalsSnapshot? Snapshot, + ExecApprovalsSnapshotFailure? Failure, + string Signature); public ExecApprovalsStore(string dataPath, IOpenClawLogger logger) : this( @@ -84,11 +108,47 @@ internal ExecApprovalsStore( _logger = logger; } - // ── Public API ──────────────────────────────────────────────────────────── + public event EventHandler? Changed + { + add + { + ThrowIfDisposed(); + if (value is null) + { + return; + } + + lock (_changeGate) + { + ThrowIfDisposed(); + _changed += value; + EnsureWatcherStartedNoLock(); + } + } + remove + { + if (value is null) + { + return; + } + + lock (_changeGate) + { + _changed -= value; + if (_changed is null) + { + DisposeWatcherNoLock(); + _lastObservedSignature = null; + _lastObservedWasFailure = false; + } + } + } + } - // No side effects; does not create the file. public ExecApprovalsResolved ResolveReadOnly(string? agentId) { + ThrowIfDisposed(); + if (_legacyFilePath is not null) { var targetStatus = LoadFile().Status; @@ -112,20 +172,19 @@ public ExecApprovalsResolved ResolveReadOnly(string? agentId) }; } - // Adds a new allowlist entry for the agent. Best-effort: never throws. - // Returns true if the entry is present after the call (added or already there), - // false if the pattern was empty or the write was skipped/failed. - // Pattern validation is non-empty only — parity with macOS. public async Task AddAllowlistEntryAsync(string? agentId, string pattern) { + ThrowIfDisposed(); + var trimmed = pattern?.Trim(); if (string.IsNullOrEmpty(trimmed)) { _logger.Debug("[EXEC-APPROVALS] AddAllowlistEntry skipped: empty pattern"); return false; } + var key = NormalizeAgentId(agentId); - bool alreadyPresent = false; + var alreadyPresent = false; var wrote = await UpdateFileAsync(file => { var agents = file.Agents!; @@ -134,132 +193,245 @@ public async Task AddAllowlistEntryAsync(string? agentId, string pattern) agent = new ExecApprovalsAgent(); agents[key] = agent; } + var allowlist = agent.Allowlist ??= []; - // Dedup case-insensitive — consistent with NormalizeAllowlistEntries (OrdinalIgnoreCase HashSet). if (allowlist.Any(e => string.Equals( - e.Pattern?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase))) + e.Pattern?.Trim(), + trimmed, + StringComparison.OrdinalIgnoreCase))) { alreadyPresent = true; return false; } + allowlist.Add(new ExecAllowlistEntry { - Id = Guid.NewGuid(), // parity with macOS UUID() + Id = Guid.NewGuid(), Pattern = trimmed, - // LastUsedAt intentionally absent: macOS addAllowlistEntry only sets {id, pattern}. - // RecordAllowlistUseAsync stamps it on first successful use. }); return true; }).ConfigureAwait(false); + return wrote || alreadyPresent; } - // Updates lastUsed* metadata for every allowlist entry whose pattern matches. - // Best-effort: never throws. No-op if the agent or pattern is not found. - // Returns true if at least one entry was updated and saved; false otherwise. - // Searches both the concrete agent bucket and the wildcard bucket ("*"), - // because ResolveReadOnly merges wildcard entries into the resolved allowlist — - // so a hit can be authorized by either source and metadata must follow. - public Task RecordAllowlistUseAsync( - string? agentId, string pattern, string? resolvedPath) + public Task RecordAllowlistUseAsync(string? agentId, string pattern, string? resolvedPath) { - if (string.IsNullOrEmpty(pattern)) return Task.FromResult(false); + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(pattern)) + { + return Task.FromResult(false); + } + var key = NormalizeAgentId(agentId); - var buckets = key == "*" ? new[] { "*" } : new[] { key, "*" }; + var buckets = key == "*" ? ["*"] : new[] { key, "*" }; return UpdateFileAsync(file => { var changed = false; foreach (var bucketKey in buckets) { if (!file.Agents!.TryGetValue(bucketKey, out var agent) || agent?.Allowlist is null) + { continue; + } + foreach (var entry in agent.Allowlist) { - if (!string.Equals(entry.Pattern?.Trim(), pattern.Trim(), + if (!string.Equals( + entry.Pattern?.Trim(), + pattern.Trim(), StringComparison.OrdinalIgnoreCase)) + { continue; + } + entry.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - entry.LastResolvedPath = resolvedPath; // Id and Pattern preserved + entry.LastResolvedPath = resolvedPath; changed = true; } } + return changed; }); } - // Side-effecting resolve: creates the file if missing, initializes agents dict. - // For startup / settings UI. Not used by the evaluator. public async Task ResolveAsync(string? agentId) { + ThrowIfDisposed(); + + ExecApprovalsResolved resolved; + ExecApprovalsChangedEventArgs? change = null; + await _lock.WaitAsync().ConfigureAwait(false); try { - var file = await EnsureFileAsync().ConfigureAwait(false); - return ResolveFromFile(file, agentId); + var result = await EnsureFileAsync().ConfigureAwait(false); + resolved = ResolveFromFile(result.File, agentId); + if (result.PersistedSnapshot is not null) + { + change = RecordManagedSnapshot(result.PersistedSnapshot, origin: null); + } } finally { _lock.Release(); } + + RaiseChanged(change); + return resolved; } - public void MigrateLegacyFileIfNeeded() => TryMigrateLegacyFile(); + public void MigrateLegacyFileIfNeeded() + { + ThrowIfDisposed(); + + if (TryMigrateLegacyFile() != LegacyMigrationStatus.Migrated) + { + return; + } + + var readOnly = LoadReadOnlySnapshot(); + if (readOnly.Snapshot is not null && readOnly.Failure is null) + { + RaiseChanged(RecordManagedSnapshot(readOnly.Snapshot, origin: null)); + } + } public async Task GetSnapshotAsync() { + ThrowIfDisposed(); + + ExecApprovalsSnapshot snapshot; + ExecApprovalsChangedEventArgs? change = null; + await _lock.WaitAsync().ConfigureAwait(false); try { if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) + { throw new IOException("Unmigrated exec approvals file is unreadable."); + } var result = LoadFile(); - if (result.Status == LoadFileStatus.Invalid) + if (result.IsInvalid) + { throw new IOException("Exec approvals file is malformed, unsupported, or untrusted."); + } if (result.Status == LoadFileStatus.Missing) { - await SaveFileAsync(NewDefaultFile()).ConfigureAwait(false); - result = LoadFile(); + var file = NewDefaultFile(); + var savedHash = await SaveFileAsync(file).ConfigureAwait(false); + snapshot = CreateSnapshot(file, exists: true, savedHash); + change = RecordManagedSnapshot(snapshot, origin: null); } - - if (result.Status != LoadFileStatus.Loaded || result.File is null) + else if (result.File is not null) + { + snapshot = CreateSnapshot(result.File, exists: true, result.Hash); + } + else + { throw new IOException("Exec approvals snapshot is unavailable."); - - return CreateSnapshot(result.File, exists: true, result.Hash!); + } } finally { _lock.Release(); } + + RaiseChanged(change); + return snapshot; } + public Task GetSnapshotReadOnlyAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + + var readOnly = LoadReadOnlySnapshot(); + ExecApprovalsReadOnlySnapshotResult result; + + lock (_changeGate) + { + ThrowIfDisposed(); + if (_changed is null || _lastObservedSignature is null) + { + _lastObservedSignature = readOnly.Signature; + _lastObservedWasFailure = readOnly.Failure is not null; + } + + if (readOnly.Snapshot is not null) + { + _lastValidPresentationSnapshot = CloneSnapshot(readOnly.Snapshot); + result = new ExecApprovalsReadOnlySnapshotResult( + CloneSnapshot(readOnly.Snapshot), + null, + null); + } + else + { + result = new ExecApprovalsReadOnlySnapshotResult( + null, + readOnly.Failure, + CloneSnapshotOrNull(_lastValidPresentationSnapshot)); + } + } + + return Task.FromResult(result); + } + + public ExecApprovalsWriterOrigin CreateWriterOrigin() + { + ThrowIfDisposed(); + return new ExecApprovalsWriterOrigin(); + } + + public Task ReplaceAsync( + string baseHash, + ExecApprovalsFile replacement, + Func? deltaValidator = null) + => ReplaceAsync(baseHash, replacement, origin: null, deltaValidator); + public async Task ReplaceAsync( string baseHash, ExecApprovalsFile replacement, + ExecApprovalsWriterOrigin? origin, Func? deltaValidator = null) { + ThrowIfDisposed(); ArgumentException.ThrowIfNullOrWhiteSpace(baseHash); ArgumentNullException.ThrowIfNull(replacement); + ExecApprovalsSnapshot? snapshot = null; + ExecApprovalsChangedEventArgs? change = null; + await _lock.WaitAsync().ConfigureAwait(false); try { if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) + { throw new IOException("Unmigrated exec approvals file is unreadable."); + } var result = LoadFile(); - if (result.Status == LoadFileStatus.Invalid) + if (result.IsInvalid) + { throw new IOException("Exec approvals file is malformed, unsupported, or untrusted."); + } - var currentHash = result.Hash ?? ComputeMissingHash(); + var currentHash = result.Hash; if (!string.Equals(baseHash.Trim(), currentHash, StringComparison.Ordinal)) + { return null; + } var currentFile = result.File ?? NewDefaultFile(); var validationError = deltaValidator?.Invoke(currentFile, replacement); if (!string.IsNullOrWhiteSpace(validationError)) + { throw new ExecApprovalsValidationException(validationError); + } var currentSocket = result.File?.Socket; var normalized = Normalize(replacement); @@ -269,15 +441,35 @@ public async Task GetSnapshotAsync() normalized.Socket = MergeSocket(normalized.Socket, currentSocket); var savedHash = await SaveFileAsync(normalized).ConfigureAwait(false); - return CreateSnapshot(normalized, exists: true, savedHash); + snapshot = CreateSnapshot(normalized, exists: true, savedHash); + change = RecordManagedSnapshot(snapshot, origin); } finally { _lock.Release(); } + + RaiseChanged(change); + return snapshot; } - // ── File I/O ────────────────────────────────────────────────────────────── + public void Dispose() + { + lock (_changeGate) + { + if (_disposed) + { + return; + } + + _disposed = true; + _changed = null; + DisposeWatcherNoLock(); + } + + _observeLock.Dispose(); + _lock.Dispose(); + } private LoadFileResult LoadFile() => LoadFile(_filePath); @@ -290,7 +482,12 @@ private LoadFileResult LoadFile(string filePath) if ((attributes & FileAttributes.Directory) != 0) { _logger.Warn("[EXEC-APPROVALS] exec-approvals.json path is a directory; applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + return new LoadFileResult( + LoadFileStatus.UntrustedPath, + null, + ComputeFailureSignature("untrusted-path", Path.GetFullPath(filePath)), + Version: null, + "Exec approvals path is a directory."); } } catch (FileNotFoundException) @@ -304,7 +501,12 @@ private LoadFileResult LoadFile(string filePath) catch (Exception ex) { _logger.Warn($"[EXEC-APPROVALS] Failed to inspect exec-approvals.json ({ex.Message}); applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + return new LoadFileResult( + LoadFileStatus.ReadFailed, + null, + ComputeFailureSignature("read-failed", $"{ex.GetType().FullName}:{ex.Message}"), + Version: null, + $"Failed to inspect exec approvals file: {ex.Message}"); } // Fail closed if a symlink/junction sits in the store path, or the file has a hard-link @@ -316,53 +518,101 @@ private LoadFileResult LoadFile(string filePath) || !ExecApprovalsPathGuard.HasSingleHardLink(filePath)) { _logger.Warn("[EXEC-APPROVALS] exec-approvals.json path is not trustworthy (reparse point or hard link); applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + return new LoadFileResult( + LoadFileStatus.UntrustedPath, + null, + ComputeFailureSignature("untrusted-path", Path.GetFullPath(filePath)), + Version: null, + "Exec approvals file path is not trustworthy."); } + try { var json = File.ReadAllText(filePath); - var file = JsonSerializer.Deserialize(json, JsonOptions); - if (file is null) + var hash = ComputeRawHash(json); + try { - _logger.Warn("[EXEC-APPROVALS] exec-approvals.json deserialized to null; applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + var file = JsonSerializer.Deserialize(json, JsonOptions); + if (file is null) + { + _logger.Warn("[EXEC-APPROVALS] exec-approvals.json deserialized to null; applying default-deny"); + return new LoadFileResult( + LoadFileStatus.MalformedJson, + null, + hash, + Version: null, + "Exec approvals file deserialized to null."); + } + + if (file.Version != 1) + { + var version = file.Version?.ToString() ?? "missing"; + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json has unsupported version {version}; applying default-deny"); + return new LoadFileResult( + LoadFileStatus.UnsupportedVersion, + null, + hash, + file.Version, + $"Unsupported exec approvals version {version}."); + } + + return new LoadFileResult( + LoadFileStatus.Loaded, + Normalize(file), + hash, + Version: 1, + string.Empty); } - if (file.Version != 1) + catch (JsonException ex) { - var version = file.Version?.ToString() ?? "missing"; - _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json has unsupported version {version}; applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json is malformed ({ex.Message}); applying default-deny"); + return new LoadFileResult( + LoadFileStatus.MalformedJson, + null, + hash, + Version: null, + $"Exec approvals file is malformed: {ex.Message}"); } - return new LoadFileResult( - LoadFileStatus.Loaded, - Normalize(file), - ComputeRawHash(json)); - } - catch (JsonException ex) - { - _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json is malformed ({ex.Message}); applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); } catch (Exception ex) { _logger.Warn($"[EXEC-APPROVALS] Failed to load exec-approvals.json ({ex.Message}); applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + return new LoadFileResult( + LoadFileStatus.ReadFailed, + null, + ComputeFailureSignature("read-failed", $"{ex.GetType().FullName}:{ex.Message}"), + Version: null, + $"Failed to load exec approvals file: {ex.Message}"); } } private LoadFileResult MissingOrUntrusted(string filePath) { if (ExecApprovalsPathGuard.IsPathTrustworthy(filePath)) - return new LoadFileResult(LoadFileStatus.Missing, null, ComputeMissingHash()); + { + return new LoadFileResult( + LoadFileStatus.Missing, + null, + ComputeMissingHash(), + Version: null, + string.Empty); + } _logger.Warn("[EXEC-APPROVALS] missing exec-approvals.json path is not trustworthy; applying default-deny"); - return new LoadFileResult(LoadFileStatus.Invalid, null, null); + return new LoadFileResult( + LoadFileStatus.UntrustedPath, + null, + ComputeFailureSignature("untrusted-path", Path.GetFullPath(filePath)), + Version: null, + "Missing exec approvals file path is not trustworthy."); } - private async Task EnsureFileAsync() + private async Task EnsureFileAsync() { if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) - return UnmigratedLegacyFallbackFile(); + { + return new EnsureFileResult(UnmigratedLegacyFallbackFile(), PersistedSnapshot: null); + } var result = LoadFile(); if (result.Status == LoadFileStatus.Loaded && result.File is not null) @@ -373,37 +623,40 @@ private async Task EnsureFileAsync() file = new ExecApprovalsFile { Version = file.Version, - Socket = file.Socket, + Socket = CloneSocket(file.Socket), Defaults = CopyDefaults(file.Defaults), Agents = [], }; - await SaveFileAsync(file).ConfigureAwait(false); + var savedHash = await SaveFileAsync(file).ConfigureAwait(false); + return new EnsureFileResult(file, CreateSnapshot(file, exists: true, savedHash)); } - return file; + + return new EnsureFileResult(file, PersistedSnapshot: null); } - if (result.Status == LoadFileStatus.Invalid) + if (result.IsInvalid) { _logger.Warn($"[EXEC-APPROVALS] Preserving unreadable exec-approvals.json at {_filePath}; using empty in-memory store"); - return UnmigratedLegacyFallbackFile(); + return new EnsureFileResult(UnmigratedLegacyFallbackFile(), PersistedSnapshot: null); } - // socket intentionally omitted in Windows v1. var newFile = NewDefaultFile(); - await SaveFileAsync(newFile).ConfigureAwait(false); + var hash = await SaveFileAsync(newFile).ConfigureAwait(false); _logger.Info($"[EXEC-APPROVALS] Created {_filePath}"); - return newFile; + return new EnsureFileResult(newFile, CreateSnapshot(newFile, exists: true, hash)); } private LegacyMigrationStatus TryMigrateLegacyFile() { if (_legacyFilePath is null) + { return LegacyMigrationStatus.NotNeeded; + } var targetResult = LoadFile(); if (targetResult.Status == LoadFileStatus.Loaded) return LegacyMigrationStatus.NotNeeded; - if (targetResult.Status == LoadFileStatus.Invalid) + if (targetResult.IsInvalid) return LegacyMigrationStatus.Blocked; var legacyResult = LoadFile(_legacyFilePath); @@ -427,6 +680,7 @@ private LegacyMigrationStatus TryMigrateLegacyFile() stream.Write(data); stream.Flush(flushToDisk: true); } + File.Move(tempPath, _filePath); try { @@ -437,17 +691,38 @@ private LegacyMigrationStatus TryMigrateLegacyFile() _logger.Warn($"[EXEC-APPROVALS] Migrated approvals to {_filePath}, but could not archive {_legacyFilePath} ({ex.Message})"); return LegacyMigrationStatus.Migrated; } + _logger.Info($"[EXEC-APPROVALS] Migrated {_legacyFilePath} to {_filePath}; archived source as {archivePath}"); return LegacyMigrationStatus.Migrated; } catch (IOException) when (File.Exists(_filePath)) { - try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + } + return LegacyMigrationStatus.NotNeeded; } catch (Exception ex) { - try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + } + _logger.Warn($"[EXEC-APPROVALS] Failed to migrate {_legacyFilePath} to {_filePath} ({ex.Message}); applying default-deny without creating a replacement file"); return LegacyMigrationStatus.Blocked; } @@ -520,8 +795,12 @@ private static string ExpandHomePrefix(string path, string home) => foreach (var value in values) { var normalized = NormalizePathValue(value); - if (normalized is not null) return normalized; + if (normalized is not null) + { + return normalized; + } } + return null; } @@ -544,11 +823,11 @@ private static ExecApprovalsResolved UnmigratedLegacyFallback(string? agentId) = private async Task SaveFileAsync(ExecApprovalsFile file) { var dir = Path.GetDirectoryName(_filePath)!; - if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } - // Refuse to write through a redirected path: a symlink/junction in the store path - // (O_NOFOLLOW analogue) or a hard-linked target (nlink==1 analogue) could divert the - // policy file to an attacker-observable or attacker-controlled location. if (!ExecApprovalsPathGuard.IsPathTrustworthy(_filePath)) { _logger.Error($"[EXEC-APPROVALS] Refusing to write {_filePath}: reparse point in store path"); @@ -566,15 +845,23 @@ private async Task SaveFileAsync(ExecApprovalsFile file) { var json = JsonSerializer.Serialize(file, JsonOptions); await File.WriteAllTextAsync(tmp, json).ConfigureAwait(false); - // Atomic replace on NTFS via MoveFileExW (MOVEFILE_REPLACE_EXISTING). File.Move(tmp, _filePath, overwrite: true); return ComputeRawHash(json); } catch (Exception ex) { _logger.Error($"[EXEC-APPROVALS] Failed to save {_filePath} ({ex.Message})"); - // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. - try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + try + { + if (File.Exists(tmp)) + { + File.Delete(tmp); + } + } + catch + { + } + throw; } } @@ -588,13 +875,7 @@ private ExecApprovalsSnapshot CreateSnapshot( _filePath, exists, hash, - new ExecApprovalsFile - { - Version = 1, - Socket = file.Socket, - Defaults = WithResolvedDefaults(file.Defaults), - Agents = file.Agents ?? [], - }); + CloneFileForSnapshot(file)); } private static string ComputeRawHash(string raw) => @@ -603,6 +884,9 @@ private static string ComputeRawHash(string raw) => private static string ComputeMissingHash() => $"missing:{ComputeRawHash(string.Empty)}"; + private static string ComputeFailureSignature(string prefix, string value) => + $"{prefix}:{ComputeRawHash(value)}"; + private static ExecApprovalsFile NewDefaultFile() => new() { @@ -628,71 +912,77 @@ private static ExecApprovalsDefaults WithResolvedDefaults(ExecApprovalsDefaults? var token = replacement?.Token ?? current?.Token; return path is null && token is null ? null - : new ExecApprovalsSocketConfig { Path = path, Token = token }; + : new ExecApprovalsSocketConfig + { + Path = path, + Token = token, + }; } - // Best-effort mutate-and-save. Serialized by the store lock. - // Never throws. Refuses to overwrite a malformed file. - // Returns true if the file was mutated and saved; false if the mutate was a no-op, - // the file was malformed/invalid, or any I/O failure occurred. private async Task UpdateFileAsync(Func mutate) { + ExecApprovalsChangedEventArgs? change = null; + var wrote = false; + await _lock.WaitAsync().ConfigureAwait(false); try { - // Migrate before any write: creating the target file here would permanently - // block TryMigrateLegacyFile and silently orphan the legacy configuration. if (TryMigrateLegacyFile() == LegacyMigrationStatus.Blocked) { - _logger.Warn("[EXEC-APPROVALS] Refusing to write exec-approvals.json: " - + "unmigrated legacy file is unreadable"); + _logger.Warn("[EXEC-APPROVALS] Refusing to write exec-approvals.json: unmigrated legacy file is unreadable"); return false; } var result = LoadFile(); - if (result.Status == LoadFileStatus.Invalid) + if (result.IsInvalid) { - _logger.Warn("[EXEC-APPROVALS] Refusing to write exec-approvals.json: " - + "file is malformed or has an unsupported version"); + _logger.Warn("[EXEC-APPROVALS] Refusing to write exec-approvals.json: file is malformed or has an unsupported version"); return false; } + var file = result.Status == LoadFileStatus.Loaded && result.File is not null ? result.File : new ExecApprovalsFile { Version = 1, Agents = [] }; - file.Agents ??= new Dictionary(); + file.Agents ??= []; - if (!mutate(file)) return false; // no-op: nothing to persist + if (!mutate(file)) + { + return false; + } - await SaveFileAsync(file).ConfigureAwait(false); - return true; + var savedHash = await SaveFileAsync(file).ConfigureAwait(false); + change = RecordManagedSnapshot(CreateSnapshot(file, exists: true, savedHash), origin: null); + wrote = true; } catch (Exception ex) { - // Any failure (incl. transient IOException on the atomic move) degrades to a - // logged warning. The atomic write guarantees the file on disk is never left corrupt. - _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json write failed " - + $"({ex.Message}); side effect skipped"); - return false; + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json write failed ({ex.Message}); side effect skipped"); + wrote = false; } finally { _lock.Release(); } - } - // ── Normalization ───────────────────────────────────────────────────────── + RaiseChanged(change); + return wrote; + } private static ExecApprovalsFile Normalize(ExecApprovalsFile file) { - // Trim socket fields; nullify if both are empty after trim. var socket = file.Socket is null ? null : NormalizeSocket(file.Socket); - - // Migrate agents["default"] → agents["main"]; "main" wins on conflicting fields. - // Null agents stays null here — EnsureFileAsync is responsible for initialization. var defaults = CopyDefaults(file.Defaults); if (file.Agents is null) - return new ExecApprovalsFile { Version = 1, Socket = socket, Defaults = defaults, Agents = null }; + { + return new ExecApprovalsFile + { + Version = 1, + Socket = socket, + Defaults = defaults, + Agents = null, + }; + } var agents = new Dictionary(file.Agents); @@ -700,43 +990,64 @@ private static ExecApprovalsFile Normalize(ExecApprovalsFile file) { agents.Remove("default"); agents["main"] = agents.TryGetValue("main", out var mainAgent) - ? MergeAgent(fallback: defaultAgent, winner: mainAgent) + ? MergeAgent(defaultAgent, mainAgent) : defaultAgent; } - // Normalize allowlist entries (dropInvalid: false — keep non-empty invalids). foreach (var key in agents.Keys.ToList()) { var agent = agents[key]; if (agent.Allowlist is not null) + { agents[key] = WithNormalizedAllowlist(agent, dropInvalid: false); + } } - return new ExecApprovalsFile { Version = 1, Socket = socket, Defaults = defaults, Agents = agents }; - } - - private static ExecApprovalsDefaults? CopyDefaults(ExecApprovalsDefaults? d) => - d is null ? null : new ExecApprovalsDefaults + return new ExecApprovalsFile { - Security = d.Security, - Ask = d.Ask, - AskFallback = d.AskFallback, - AutoAllowSkills = d.AutoAllowSkills, + Version = 1, + Socket = socket, + Defaults = defaults, + Agents = agents, }; + } + + private static ExecApprovalsDefaults? CopyDefaults(ExecApprovalsDefaults? defaults) => + defaults is null + ? null + : new ExecApprovalsDefaults + { + Security = defaults.Security, + Ask = defaults.Ask, + AskFallback = defaults.AskFallback, + AutoAllowSkills = defaults.AutoAllowSkills, + }; - private static ExecApprovalsSocketConfig? NormalizeSocket(ExecApprovalsSocketConfig s) + private static ExecApprovalsSocketConfig? NormalizeSocket(ExecApprovalsSocketConfig socket) { - var path = string.IsNullOrWhiteSpace(s.Path) ? null : s.Path.Trim(); - var token = string.IsNullOrWhiteSpace(s.Token) ? null : s.Token.Trim(); - return (path is null && token is null) ? null : new ExecApprovalsSocketConfig { Path = path, Token = token }; + var path = string.IsNullOrWhiteSpace(socket.Path) ? null : socket.Path.Trim(); + var token = string.IsNullOrWhiteSpace(socket.Token) ? null : socket.Token.Trim(); + return path is null && token is null + ? null + : new ExecApprovalsSocketConfig + { + Path = path, + Token = token, + }; } - // winner's non-null fields take precedence; allowlists are concatenated (fallback first). private static ExecApprovalsAgent MergeAgent(ExecApprovalsAgent fallback, ExecApprovalsAgent winner) { var allowlist = new List(); - if (fallback.Allowlist is not null) allowlist.AddRange(fallback.Allowlist); - if (winner.Allowlist is not null) allowlist.AddRange(winner.Allowlist); + if (fallback.Allowlist is not null) + { + allowlist.AddRange(fallback.Allowlist); + } + + if (winner.Allowlist is not null) + { + allowlist.AddRange(winner.Allowlist); + } return new ExecApprovalsAgent { @@ -755,37 +1066,42 @@ private static ExecApprovalsAgent WithNormalizedAllowlist(ExecApprovalsAgent age Ask = agent.Ask, AskFallback = agent.AskFallback, AutoAllowSkills = agent.AutoAllowSkills, - Allowlist = NormalizeAllowlistEntries(agent.Allowlist!, dropInvalid) - is { Count: > 0 } list ? list : null, + Allowlist = NormalizeAllowlistEntries(agent.Allowlist!, dropInvalid) is { Count: > 0 } list ? list : null, }; - // Mirrors macOS normalizeAllowlistEntries. - // dropInvalid=false: discard only null/empty patterns; keep non-empty ones regardless of validity. - // dropInvalid=true: same in v1 — pattern validity beyond non-empty is enforced by the allowlist - // matcher, not here. The flag is preserved for API symmetry with macOS. internal static List NormalizeAllowlistEntries( - IEnumerable entries, bool dropInvalid) + IEnumerable entries, + bool dropInvalid) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var result = new List(); foreach (var entry in entries) { var pattern = entry.Pattern?.Trim(); - if (string.IsNullOrEmpty(pattern)) continue; - if (!seen.Add(pattern)) continue; - result.Add(pattern == entry.Pattern ? entry : new ExecAllowlistEntry - { - Id = entry.Id, - Pattern = pattern, - LastUsedAt = entry.LastUsedAt, - LastResolvedPath = entry.LastResolvedPath, - }); + if (string.IsNullOrEmpty(pattern)) + { + continue; + } + + if (!seen.Add(pattern)) + { + continue; + } + + result.Add(pattern == entry.Pattern + ? CloneAllowlistEntry(entry) + : new ExecAllowlistEntry + { + Id = entry.Id, + Pattern = pattern, + LastUsedAt = entry.LastUsedAt, + LastResolvedPath = entry.LastResolvedPath, + }); } + return result; } - // ── Cascade resolution ──────────────────────────────────────────────────── - private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, string? agentId) { var id = NormalizeAgentId(agentId); @@ -800,10 +1116,16 @@ private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, str var askFallback = agentEntry?.AskFallback ?? wildcardEntry?.AskFallback ?? defaults?.AskFallback ?? ExecSecurity.Deny; var autoAllowSkills = agentEntry?.AutoAllowSkills ?? wildcardEntry?.AutoAllowSkills ?? defaults?.AutoAllowSkills ?? false; - // Allowlist: wildcard first, then agent; then normalize dropInvalid=true. var combined = new List(); - if (wildcardEntry?.Allowlist is not null) combined.AddRange(wildcardEntry.Allowlist); - if (agentEntry?.Allowlist is not null) combined.AddRange(agentEntry.Allowlist); + if (wildcardEntry?.Allowlist is not null) + { + combined.AddRange(wildcardEntry.Allowlist); + } + + if (agentEntry?.Allowlist is not null) + { + combined.AddRange(agentEntry.Allowlist); + } return new ExecApprovalsResolved { @@ -851,6 +1173,440 @@ private static ExecApprovalsResolved FailClosedResolved(string agentId) => // null/empty agentId → "main". Mirrors macOS. Evaluator does not need to know this. private static string NormalizeAgentId(string? agentId) => string.IsNullOrWhiteSpace(agentId) ? "main" : agentId; + + private ReadOnlySnapshotLoadResult LoadReadOnlySnapshot() + { + LoadFileResult result; + if (_legacyFilePath is not null) + { + result = LoadFile(); + var legacyStatus = LoadFile(_legacyFilePath).Status; + if (result.Status == LoadFileStatus.Missing + && legacyStatus != LoadFileStatus.Missing) + { + var failure = new ExecApprovalsSnapshotFailure( + ExecApprovalsSnapshotFailureKind.LegacyMigrationRequired, + ComputeFailureSignature("legacy-migration-required", Path.GetFullPath(_legacyFilePath)), + Version: null, + "Legacy exec approvals must be migrated before a read-only snapshot is available."); + return new ReadOnlySnapshotLoadResult(null, failure, failure.Hash); + } + } + else + { + result = LoadFile(); + } + + if (result.Status == LoadFileStatus.Missing) + { + var snapshot = CreateSnapshot(NewDefaultFile(), exists: false, result.Hash); + return new ReadOnlySnapshotLoadResult(snapshot, null, snapshot.Hash); + } + + if (result.Status == LoadFileStatus.Loaded && result.File is not null) + { + var snapshot = CreateSnapshot(result.File, exists: true, result.Hash); + return new ReadOnlySnapshotLoadResult(snapshot, null, snapshot.Hash); + } + + var typedFailure = CreateFailure(result); + return new ReadOnlySnapshotLoadResult(null, typedFailure, typedFailure.Hash); + } + + private static ExecApprovalsSnapshotFailure CreateFailure(LoadFileResult result) + { + var kind = result.Status switch + { + LoadFileStatus.UntrustedPath => ExecApprovalsSnapshotFailureKind.UntrustedPath, + LoadFileStatus.UnsupportedVersion => ExecApprovalsSnapshotFailureKind.UnsupportedVersion, + LoadFileStatus.MalformedJson => ExecApprovalsSnapshotFailureKind.MalformedJson, + LoadFileStatus.ReadFailed => ExecApprovalsSnapshotFailureKind.ReadFailed, + _ => throw new InvalidOperationException($"Unsupported failure status {result.Status}."), + }; + + return new ExecApprovalsSnapshotFailure(kind, result.Hash, result.Version, result.Message); + } + + private ExecApprovalsChangedEventArgs? RecordManagedSnapshot( + ExecApprovalsSnapshot snapshot, + ExecApprovalsWriterOrigin? origin) + { + lock (_changeGate) + { + if (_disposed) + { + return null; + } + + var snapshotCopy = CloneSnapshot(snapshot); + var kind = _lastObservedWasFailure + ? ExecApprovalsChangeKind.SnapshotRecovered + : ExecApprovalsChangeKind.SnapshotUpdated; + + _lastObservedWasFailure = false; + _lastValidPresentationSnapshot = CloneSnapshot(snapshot); + if (_changed is null) + { + _lastObservedSignature = null; + return null; + } + + _lastObservedSignature = snapshot.Hash; + EnsureWatcherStartedNoLock(); + + return new ExecApprovalsChangedEventArgs( + ++_changeSequence, + kind, + snapshotCopy.Hash, + snapshotCopy.File.Version, + snapshotCopy, + failure: null, + lastValidSnapshot: null, + origin); + } + } + + private void EnsureWatcherStartedNoLock() + { + while (!_disposed && _changed is not null && _watcher is null) + { + var targetDirectoryPath = Path.GetDirectoryName(_filePath)!; + string watcherDirectoryPath; + string observedPath; + NotifyFilters notifyFilter; + if (Directory.Exists(targetDirectoryPath)) + { + watcherDirectoryPath = targetDirectoryPath; + observedPath = _filePath; + notifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size; + } + else + { + observedPath = targetDirectoryPath; + while (true) + { + var parentPath = Path.GetDirectoryName(observedPath); + if (string.IsNullOrWhiteSpace(parentPath)) + { + return; + } + + if (Directory.Exists(parentPath)) + { + watcherDirectoryPath = parentPath; + notifyFilter = NotifyFilters.DirectoryName; + break; + } + + observedPath = parentPath; + } + } + + try + { + _watcher = new FileSystemWatcher(watcherDirectoryPath, Path.GetFileName(observedPath)) + { + IncludeSubdirectories = false, + NotifyFilter = notifyFilter, + }; + _watcherObservedPath = observedPath; + _watcher.Changed += OnWatcherChanged; + _watcher.Created += OnWatcherChanged; + _watcher.Deleted += OnWatcherChanged; + _watcher.Renamed += OnWatcherRenamed; + _watcher.Error += OnWatcherError; + _watcher.EnableRaisingEvents = true; + } + catch (Exception ex) + { + _logger.Warn($"[EXEC-APPROVALS] Failed to observe exec-approvals.json changes ({ex.Message})"); + DisposeWatcherNoLock(); + return; + } + + if (PathsEqual(observedPath, _filePath) || !Directory.Exists(observedPath)) + { + return; + } + + DisposeWatcherNoLock(); + } + } + + private void DisposeWatcherNoLock() + { + if (_watcher is null) + { + return; + } + + _watcher.EnableRaisingEvents = false; + _watcher.Changed -= OnWatcherChanged; + _watcher.Created -= OnWatcherChanged; + _watcher.Deleted -= OnWatcherChanged; + _watcher.Renamed -= OnWatcherRenamed; + _watcher.Error -= OnWatcherError; + _watcher.Dispose(); + _watcher = null; + _watcherObservedPath = null; + } + + private void OnWatcherChanged(object sender, FileSystemEventArgs e) + { + HandleWatcherPathChange(e.FullPath, oldFullPath: null); + } + + private void OnWatcherRenamed(object sender, RenamedEventArgs e) + { + HandleWatcherPathChange(e.FullPath, e.OldFullPath); + } + + private void HandleWatcherPathChange(string fullPath, string? oldFullPath) + { + var shouldObserve = false; + lock (_changeGate) + { + if (_disposed + || _changed is null + || _watcherObservedPath is null + || (!PathsEqual(fullPath, _watcherObservedPath) + && (oldFullPath is null || !PathsEqual(oldFullPath, _watcherObservedPath)))) + { + return; + } + + if (!PathsEqual(_watcherObservedPath, _filePath)) + { + DisposeWatcherNoLock(); + EnsureWatcherStartedNoLock(); + } + + shouldObserve = Directory.Exists(Path.GetDirectoryName(_filePath)!); + } + + if (shouldObserve) + { + QueueWatcherObservation(); + } + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + var message = e.GetException()?.Message ?? "unknown watcher error"; + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json watcher failed ({message})"); + QueueWatcherObservation(); + } + + private void QueueWatcherObservation() + { + lock (_changeGate) + { + if (_disposed || _changed is null) + { + return; + } + } + + _ = Task.Run(ObserveExternalChangeAsync); + } + + private async Task ObserveExternalChangeAsync() + { + var storeLockHeld = false; + try + { + await _observeLock.WaitAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + + ExecApprovalsChangedEventArgs? change = null; + try + { + await _lock.WaitAsync().ConfigureAwait(false); + storeLockHeld = true; + var readOnly = await LoadObservedSnapshotAsync().ConfigureAwait(false); + lock (_changeGate) + { + if (_disposed || _changed is null) + { + return; + } + + if (string.Equals(_lastObservedSignature, readOnly.Signature, StringComparison.Ordinal)) + { + return; + } + + _lastObservedSignature = readOnly.Signature; + if (readOnly.Snapshot is not null) + { + var snapshot = CloneSnapshot(readOnly.Snapshot); + var kind = _lastObservedWasFailure + ? ExecApprovalsChangeKind.SnapshotRecovered + : ExecApprovalsChangeKind.SnapshotUpdated; + _lastObservedWasFailure = false; + _lastValidPresentationSnapshot = CloneSnapshot(snapshot); + change = new ExecApprovalsChangedEventArgs( + ++_changeSequence, + kind, + snapshot.Hash, + snapshot.File.Version, + snapshot, + failure: null, + lastValidSnapshot: null, + origin: null); + } + else if (readOnly.Failure is not null) + { + _lastObservedWasFailure = true; + change = new ExecApprovalsChangedEventArgs( + ++_changeSequence, + ExecApprovalsChangeKind.SnapshotInvalid, + readOnly.Failure.Hash, + readOnly.Failure.Version, + snapshot: null, + readOnly.Failure, + CloneSnapshotOrNull(_lastValidPresentationSnapshot), + origin: null); + } + } + } + catch (Exception ex) + { + _logger.Warn($"[EXEC-APPROVALS] Failed to observe exec-approvals.json changes ({ex.Message})"); + } + finally + { + if (storeLockHeld) + { + try + { + _lock.Release(); + } + catch (ObjectDisposedException) + { + } + } + + try + { + _observeLock.Release(); + } + catch (ObjectDisposedException) + { + } + } + + RaiseChanged(change); + } + + private async Task LoadObservedSnapshotAsync() + { + var readOnly = LoadReadOnlySnapshot(); + for (var attempt = 1; + attempt <= 3 && readOnly.Failure?.Kind == ExecApprovalsSnapshotFailureKind.ReadFailed; + attempt++) + { + await Task.Delay(TimeSpan.FromMilliseconds(attempt * 25)).ConfigureAwait(false); + readOnly = LoadReadOnlySnapshot(); + } + + return readOnly; + } + + private void RaiseChanged(ExecApprovalsChangedEventArgs? args) + { + if (args is null) + { + return; + } + + Delegate[] handlers; + lock (_changeGate) + { + handlers = _changed?.GetInvocationList() ?? []; + } + + foreach (EventHandler handler in handlers) + { + try + { + handler(this, args); + } + catch (Exception ex) + { + _logger.Warn($"[EXEC-APPROVALS] Changed subscriber {handler.Method.DeclaringType?.Name}.{handler.Method.Name} threw ({ex.Message})"); + } + } + } + + private static ExecApprovalsSnapshot? CloneSnapshotOrNull(ExecApprovalsSnapshot? snapshot) => + snapshot is null ? null : CloneSnapshot(snapshot); + + private static ExecApprovalsSnapshot CloneSnapshot(ExecApprovalsSnapshot snapshot) => + new(snapshot.Path, snapshot.Exists, snapshot.Hash, CloneFileForSnapshot(snapshot.File)); + + private static ExecApprovalsFile CloneFileForSnapshot(ExecApprovalsFile file) => + new() + { + Version = 1, + Socket = CloneSocket(file.Socket), + Defaults = WithResolvedDefaults(file.Defaults), + Agents = CloneAgents(file.Agents) ?? [], + }; + + private static Dictionary? CloneAgents( + Dictionary? agents) + { + if (agents is null) + { + return null; + } + + return agents.ToDictionary( + pair => pair.Key, + pair => CloneAgent(pair.Value), + StringComparer.Ordinal); + } + + private static ExecApprovalsAgent CloneAgent(ExecApprovalsAgent agent) => + new() + { + Security = agent.Security, + Ask = agent.Ask, + AskFallback = agent.AskFallback, + AutoAllowSkills = agent.AutoAllowSkills, + Allowlist = agent.Allowlist?.Select(CloneAllowlistEntry).ToList(), + }; + + private static ExecAllowlistEntry CloneAllowlistEntry(ExecAllowlistEntry entry) => + new() + { + Id = entry.Id, + Pattern = entry.Pattern, + LastUsedAt = entry.LastUsedAt, + LastResolvedPath = entry.LastResolvedPath, + }; + + private static ExecApprovalsSocketConfig? CloneSocket(ExecApprovalsSocketConfig? socket) => + socket is null + ? null + : new ExecApprovalsSocketConfig + { + Path = socket.Path, + Token = socket.Token, + }; + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ExecApprovalsStore)); + } + } } internal sealed class ExecApprovalsValidationException(string message) : Exception(message); diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 5ee1101e2..d6b1238bb 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -3,6 +3,7 @@ using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; using OpenClawTray.Helpers; +using OpenClawTray.Presentation; using OpenClawTray.Services; using System; using System.Collections.Generic; @@ -155,8 +156,24 @@ private void WireAppCapabilityHandlers() try { var converted = Convert.ChangeType(value, prop.PropertyType); - prop.SetValue(_settings, converted); - _settings.Save(); + if (TryGetStoreManagedPermissionValue(name, converted, out var permissionValue)) + { + if (!TryPersistPermissionSetting( + ref _appCapabilityPermissionWriteOrigin, + $"app.settings.set({name})", + edit => ApplyStoreManagedPermissionSetting(edit, name, permissionValue), + settings => ApplyStoreManagedPermissionSetting(settings, name, permissionValue), + out var persistError)) + { + return new { error = persistError ?? $"Failed to persist setting '{name}'" }; + } + } + else + { + prop.SetValue(_settings, converted); + _settings.Save(); + } + OnSettingsSaved(this, EventArgs.Empty); var runtimeError = McpRuntimeStatePolicy.GetSettingsSetError( name, @@ -398,6 +415,94 @@ private void WireAppCapabilityHandlers() }; } + private static bool TryGetStoreManagedPermissionValue(string name, object? converted, out bool value) + { + switch (name) + { + case nameof(SettingsManager.EnableNodeMode): + case nameof(SettingsManager.EnableMcpServer): + case nameof(SettingsManager.NodeCanvasEnabled): + case nameof(SettingsManager.NodeScreenEnabled): + case nameof(SettingsManager.NodeCameraEnabled): + case nameof(SettingsManager.NodeLocationEnabled): + case nameof(SettingsManager.NodeBrowserProxyEnabled): + case nameof(SettingsManager.NodeTtsEnabled): + value = converted is bool booleanValue + ? booleanValue + : throw new InvalidCastException($"Setting '{name}' must be a boolean."); + return true; + default: + value = false; + return false; + } + } + + private static void ApplyStoreManagedPermissionSetting(ISettingsEditor edit, string name, bool value) + { + switch (name) + { + case nameof(SettingsManager.EnableNodeMode): + edit.EnableNodeMode = value; + break; + case nameof(SettingsManager.EnableMcpServer): + edit.EnableMcpServer = value; + break; + case nameof(SettingsManager.NodeCanvasEnabled): + edit.NodeCanvasEnabled = value; + break; + case nameof(SettingsManager.NodeScreenEnabled): + edit.NodeScreenEnabled = value; + break; + case nameof(SettingsManager.NodeCameraEnabled): + edit.NodeCameraEnabled = value; + break; + case nameof(SettingsManager.NodeLocationEnabled): + edit.NodeLocationEnabled = value; + break; + case nameof(SettingsManager.NodeBrowserProxyEnabled): + edit.NodeBrowserProxyEnabled = value; + break; + case nameof(SettingsManager.NodeTtsEnabled): + edit.NodeTtsEnabled = value; + break; + default: + throw new InvalidOperationException($"Setting '{name}' is not store-managed."); + } + } + + private static void ApplyStoreManagedPermissionSetting(SettingsManager settings, string name, bool value) + { + switch (name) + { + case nameof(SettingsManager.EnableNodeMode): + settings.EnableNodeMode = value; + break; + case nameof(SettingsManager.EnableMcpServer): + settings.EnableMcpServer = value; + break; + case nameof(SettingsManager.NodeCanvasEnabled): + settings.NodeCanvasEnabled = value; + break; + case nameof(SettingsManager.NodeScreenEnabled): + settings.NodeScreenEnabled = value; + break; + case nameof(SettingsManager.NodeCameraEnabled): + settings.NodeCameraEnabled = value; + break; + case nameof(SettingsManager.NodeLocationEnabled): + settings.NodeLocationEnabled = value; + break; + case nameof(SettingsManager.NodeBrowserProxyEnabled): + settings.NodeBrowserProxyEnabled = value; + break; + case nameof(SettingsManager.NodeTtsEnabled): + settings.NodeTtsEnabled = value; + break; + default: + throw new InvalidOperationException($"Setting '{name}' is not store-managed."); + } + } + private async Task GetPendingApprovalsForMcpAsync() { var client = GatewayClient; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index dba375624..f0c2556fc 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -41,7 +41,7 @@ namespace OpenClawTray; -public partial class App : Application, OpenClawTray.Services.IAppCommands +public partial class App : Application, OpenClawTray.Services.IAppCommands, IPermissionsPageRuntimeHost { internal static readonly UpdatumManager AppUpdater = new("openclaw", "openclaw-windows-node") { @@ -75,6 +75,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands new Dictionary { [typeof(Pages.SettingsPage)] = typeof(SettingsPageViewModel), + [typeof(Pages.PermissionsPage)] = typeof(PermissionsPageViewModel), }; /// The root service provider, or null before startup / after shutdown. @@ -137,6 +138,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands /// public event EventHandler? SettingsChanged; public event EventHandler? ChatProviderChanged; + private event EventHandler? PermissionsRuntimeChanged; /// /// Ensures the managed SSH tunnel is started using the current settings. @@ -221,6 +223,8 @@ public IntPtr GetHubWindowHandle() private WeakReference? _connectionToggleRef; private bool _suspendConnectionToggleEvent; private string? _lastManagerConnectedSideEffectsKey; + private SettingsWriteOrigin? _trayPermissionWriteOrigin; + private SettingsWriteOrigin? _appCapabilityPermissionWriteOrigin; // FrozenDictionary for O(1) case-insensitive notification type → setting lookup — no per-call allocation. private static readonly System.Collections.Frozen.FrozenDictionary> s_notifTypeMap = @@ -471,7 +475,7 @@ private void InitializeServiceProvider() } var dispatcher = new WinUIDispatcher(_dispatcherQueue); - var context = new AppServiceContext(dispatcher, this, _settings); + var context = new AppServiceContext(dispatcher, this, _settings, ExecApprovalsStore, this); var services = new ServiceCollection(); services.AddOpenClawTrayCore(context); @@ -1512,6 +1516,48 @@ private void LocalDisconnectCleanup() _trayMenuWindow?.HideCascade(); } + private SettingsWriteOrigin GetOrCreateSettingsWriteOrigin( + ref SettingsWriteOrigin? originField, + ISettingsStore store) + => originField ??= store.CreateOrigin(); + + private bool TryPersistPermissionSetting( + ref SettingsWriteOrigin? originField, + string writerName, + Action edit, + Action fallbackEdit, + out string? error) + { + try + { + if (SettingsStore is { } store) + { + store.Update(GetOrCreateSettingsWriteOrigin(ref originField, store), edit); + error = null; + return true; + } + + if (_settings == null) + { + error = "Settings are not initialized"; + Logger.Warn($"[App] {writerName} could not persist a permission setting because {error.ToLowerInvariant()}."); + return false; + } + + Logger.Warn($"[App] {writerName} could not reach ISettingsStore. Falling back to SettingsManager.Save."); + fallbackEdit(_settings); + _settings.Save(); + error = null; + return true; + } + catch (Exception ex) + { + error = ex.Message; + Logger.Warn($"[App] {writerName} failed to persist a permission setting: {ex.Message}"); + return false; + } + } + private void BuildTrayMenuPopup(TrayMenuWindow menu) { // Preview data must be applied before snapshot capture so the injected @@ -1520,7 +1566,18 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) var snapshot = CaptureTrayMenuSnapshot(); var callbacks = new TrayMenuCallbacks( DispatchAction: action => OnTrayMenuItemClicked(null, action), - SaveAndReconnect: () => { _settings?.Save(); ReconnectWithSyncedBrowserProxyForward(); }, + UpdatePermissionAndReconnect: (settingName, edit, fallbackEdit) => + { + if (TryPersistPermissionSetting( + ref _trayPermissionWriteOrigin, + $"tray permissions flyout ({settingName})", + edit, + fallbackEdit, + out _)) + { + ReconnectWithSyncedBrowserProxyForward(); + } + }, TrackConnectionToggle: toggle => _connectionToggleRef = new WeakReference(toggle), IsConnectionToggleSuspended: () => _suspendConnectionToggleEvent); var builder = new TrayMenuStateBuilder(snapshot, _permToggleActions, callbacks); @@ -1877,6 +1934,7 @@ record = SyncGatewayBrowserProxyForward(record); private void ReconnectWithSyncedBrowserProxyForward() { SyncActiveGatewayBrowserProxyForward(); + _nodeService?.RefreshMcpOnlyCapabilities(); _ = _connectionManager?.ReconnectAsync(); } @@ -2192,6 +2250,7 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna UpdateTrayIcon(); SyncConnectionToggle(mapped, snap.OverallState); UpdateConnectionIssueNotification(snap); + PermissionsRuntimeChanged?.Invoke(this, EventArgs.Empty); if (mapped is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) { // Dismiss the tray menu on state change — it will capture fresh data on next open @@ -2314,7 +2373,11 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) { // Status field is maintained by OnManagerStateChanged — no write needed here. UpdateTrayIcon(); - OnUiThread(UpdateStatusDetailWindow); + OnUiThread(() => + { + UpdateStatusDetailWindow(); + PermissionsRuntimeChanged?.Invoke(this, EventArgs.Empty); + }); } // Don't show "connected" toast if waiting for pairing - we'll show pairing status instead @@ -2985,11 +3048,16 @@ private void OnAppStateChanged(object? sender, System.ComponentModel.PropertyCha case nameof(AppState.UsageCost): case nameof(AppState.Nodes): UpdateStatusDetailWindow(); + if (e.PropertyName == nameof(AppState.Nodes)) + PermissionsRuntimeChanged?.Invoke(this, EventArgs.Empty); break; case nameof(AppState.Channels): UpdateChannelIssueNotifications(_appState.Channels); UpdateStatusDetailWindow(); break; + case nameof(AppState.Config): + PermissionsRuntimeChanged?.Invoke(this, EventArgs.Empty); + break; case nameof(AppState.CurrentActivity): UpdateTrayIcon(); break; @@ -3566,6 +3634,7 @@ void ApplyUiSettingsAndNotify() if (_hubWindow is { IsClosed: false }) _hubWindow.RefreshDiagnosticsNavVisibility(); SettingsChanged?.Invoke(this, EventArgs.Empty); + PermissionsRuntimeChanged?.Invoke(this, EventArgs.Empty); } if (_dispatcherQueue != null && !_dispatcherQueue.HasThreadAccess) @@ -4158,6 +4227,37 @@ void IAppCommands.Disconnect() void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow(); void IAppCommands.NotifySettingsSaved() => OnSettingsSaved(this, EventArgs.Empty); Task IAppCommands.ResendOpenTelemetryProbeAsync() => ResendOpenTelemetryProbeAsync(); + event EventHandler? IPermissionsPageRuntimeHost.Changed + { + add => PermissionsRuntimeChanged += value; + remove => PermissionsRuntimeChanged -= value; + } + GatewayConnectionSnapshot IPermissionsPageRuntimeHost.ConnectionSnapshot => _connectionManager?.CurrentSnapshot ?? GatewayConnectionSnapshot.Idle; + GatewayNodeInfo[] IPermissionsPageRuntimeHost.Nodes => _appState?.Nodes ?? Array.Empty(); + string? IPermissionsPageRuntimeHost.LocalNodeDeviceId => _nodeService?.FullDeviceId; + JsonElement? IPermissionsPageRuntimeHost.GatewayConfig => _appState?.Config; + string? IPermissionsPageRuntimeHost.McpStartupError => _nodeService?.McpStartupError; + string IPermissionsPageRuntimeHost.McpEndpoint => NodeService.McpServerUrl; + bool IPermissionsPageRuntimeHost.IsMcpTokenReady => File.Exists(NodeService.McpTokenPath); + int IPermissionsPageRuntimeHost.McpServedCapabilityCount => NodeCapabilityGating.CountMcpServedCapabilities(_settings); + PermissionsVoiceSetupRequirement IPermissionsPageRuntimeHost.VoiceSetupRequirement => GetPermissionsVoiceSetupRequirement(); + + private PermissionsVoiceSetupRequirement GetPermissionsVoiceSetupRequirement() + { + var needsSpeechModel = _settings?.NodeSttEnabled == true + && SpeechSetupReadiness.IsConfiguredSttModelSetupRequired(_settings); + var needsVoiceSetup = _settings?.NodeTtsEnabled == true + && _settings is not null + && SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired(_settings); + + return (needsSpeechModel, needsVoiceSetup) switch + { + (true, true) => PermissionsVoiceSetupRequirement.SpeechModelAndVoiceSetup, + (true, false) => PermissionsVoiceSetupRequirement.SpeechModel, + (false, true) => PermissionsVoiceSetupRequirement.VoiceSetup, + _ => PermissionsVoiceSetupRequirement.None, + }; + } private void ToggleChannel(string channelName) => AsyncEventHandlerGuard.Run( @@ -4215,18 +4315,24 @@ private async Task ToggleAutoStartAsync() /// Persists the auto-start setting and applies the Windows OS registration in the original /// order (save, then await the OS write, then notify). Returns true only when the OS write /// and notify complete, so the caller shows its saved confirmation only on success. The save - /// is marked as a store self-write so it does not echo an external-change reload. + /// is tagged with the originating writer so other active settings consumers refresh while the + /// triggering view model ignores its own change event. /// - public async Task ApplyAutoStart(bool autoStart) + public async Task ApplyAutoStart(SettingsWriteOrigin origin, bool autoStart) { if (_settings == null) return false; try { - _settings.AutoStart = autoStart; - using (SettingsStore?.BeginSelfWrite()) + if (SettingsStore is { } store) + { + store.Update(origin, edit => edit.AutoStart = autoStart); + } + else { + _settings.AutoStart = autoStart; _settings.Save(); } + await AutoStartManager.SetAutoStartAsync(autoStart); OnSettingsSaved(this, EventArgs.Empty); return true; @@ -4523,17 +4629,26 @@ public Task SpeakChatTextAsync(string text) => /// /// Sets speaker mute from any surface (chat window, chat page, voice settings) and persists it. + /// The public path publishes a null-origin settings change, so an open Settings page still + /// reflects a mute toggled elsewhere. /// public void SetChatSpeakerMuted(bool muted) + => SetChatSpeakerMuted(muted, origin: null); + + private void SetChatSpeakerMuted(bool muted, SettingsWriteOrigin? origin) { if (_chatCoordinator is { } c) c.IsMuted = muted; - // Persist to settings - if (_settings != null) + + if (_settings != null && SettingsStore is { } store) + { + store.Update(origin, edit => edit.VoiceTtsEnabled = !muted); + } + else if (_settings != null) { _settings.VoiceTtsEnabled = !muted; _settings.Save(); } - // Broadcast to all subscribers + SpeakerMuteChanged?.Invoke(muted); } @@ -4675,6 +4790,13 @@ await SafeShutdownStepAsync("standalone voice service", async () => _pairingApprovalDialog = null; }); + SafeShutdownStep("app state observers", () => + { + if (_appState != null) + _appState.PropertyChanged -= OnAppStateChanged; + PermissionsRuntimeChanged = null; + }); + // Close windows explicitly for deterministic shutdown tracing. SafeShutdownStep("chat window", () => { _chatWindow?.ForceClose(); _chatWindow = null; }); SafeShutdownStep("setup window", () => { _setupWindow?.Close(); _setupWindow = null; }); diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index d01972a90..80fdc7f63 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -7,6 +7,7 @@ using OpenClaw.Connection; using OpenClaw.Shared; using OpenClawTray.Helpers; +using OpenClawTray.Presentation; using OpenClawTray.Services; using System; using System.Collections.Generic; @@ -46,6 +47,7 @@ public sealed partial class ConnectionPage : Page private GatewayConnectionSnapshot _lastSnapshot = GatewayConnectionSnapshot.Idle; private bool _suppressNodeModeToggle; private bool _suppressConnectionToggle; + private SettingsWriteOrigin? _nodeModeSettingsOrigin; private ConnectionPagePlan _currentPlan = new(); private GatewayHostAccessPlan _activeHostAccessPlan = GatewayHostAccessPlan.None(); private bool _gatewayHostActionInProgress; @@ -3364,10 +3366,9 @@ private void OnConnectionToggled(object sender, RoutedEventArgs e) private void OnNodeModeToggled(object sender, RoutedEventArgs e) { if (_suppressNodeModeToggle) return; - var settings = CurrentApp.Settings; - if (settings == null) return; - settings.EnableNodeMode = NodeModeToggle.IsOn; - settings.Save(); + if (!TryPersistNodeModeSetting(NodeModeToggle.IsOn)) + return; + // Toggling Node mode forces a full reconnect of the gateway WS so // the role change registers; mask the brief transient window so the // gateway/operator visuals don't flicker through "Disconnected". @@ -3376,6 +3377,36 @@ private void OnNodeModeToggled(object sender, RoutedEventArgs e) RefreshFromSnapshot(_lastSnapshot); } + private bool TryPersistNodeModeSetting(bool enabled) + { + try + { + if (CurrentApp.SettingsStore is { } store) + { + _nodeModeSettingsOrigin ??= store.CreateOrigin(); + store.Update(_nodeModeSettingsOrigin, edit => edit.EnableNodeMode = enabled); + return true; + } + + var settings = CurrentApp.SettingsOrNull; + if (settings == null) + { + Services.Logger.Warn("[ConnectionPage] Could not persist EnableNodeMode because settings are unavailable."); + return false; + } + + Services.Logger.Warn("[ConnectionPage] ISettingsStore unavailable for EnableNodeMode. Falling back to SettingsManager.Save."); + settings.EnableNodeMode = enabled; + settings.Save(); + return true; + } + catch (Exception ex) + { + Services.Logger.Warn($"[ConnectionPage] Failed to persist EnableNodeMode: {ex.Message}"); + return false; + } + } + private static bool IsStableState(OverallConnectionState s) => s is OverallConnectionState.Connected or OverallConnectionState.Ready diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml index d98862789..3ffa0a59a 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -300,7 +300,7 @@ HorizontalAlignment="Center"/>