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 | - |
 <!-- LEDGER:END -->
 
 ## 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<ExecApprovalsChangedEventArgs>? Changed;
+
+    Task<ExecApprovalsReadOnlySnapshotResult> GetSnapshotReadOnlyAsync(CancellationToken cancellationToken = default);
+
+    ExecApprovalsWriterOrigin CreateWriterOrigin();
+
+    Task<ExecApprovalsSnapshot?> ReplaceAsync(
+        string baseHash,
+        ExecApprovalsFile replacement,
+        ExecApprovalsWriterOrigin? origin,
+        Func<ExecApprovalsFile, ExecApprovalsFile, string?>? 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<ExecApprovalsChangedEventArgs>? _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<ExecApprovalsChangedEventArgs>? 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<bool> 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<bool> 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<bool> RecordAllowlistUseAsync(
-        string? agentId, string pattern, string? resolvedPath)
+    public Task<bool> 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<ExecApprovalsResolved> 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<ExecApprovalsSnapshot> 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<ExecApprovalsReadOnlySnapshotResult> 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<ExecApprovalsSnapshot?> ReplaceAsync(
+        string baseHash,
+        ExecApprovalsFile replacement,
+        Func<ExecApprovalsFile, ExecApprovalsFile, string?>? deltaValidator = null)
+        => ReplaceAsync(baseHash, replacement, origin: null, deltaValidator);
+
     public async Task<ExecApprovalsSnapshot?> ReplaceAsync(
         string baseHash,
         ExecApprovalsFile replacement,
+        ExecApprovalsWriterOrigin? origin,
         Func<ExecApprovalsFile, ExecApprovalsFile, string?>? 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<ExecApprovalsSnapshot> 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<ExecApprovalsFile>(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<ExecApprovalsFile>(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<ExecApprovalsFile> EnsureFileAsync()
+    private async Task<EnsureFileResult> 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<ExecApprovalsFile> 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<string> 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<string> 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<bool> UpdateFileAsync(Func<ExecApprovalsFile, bool> 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<string, ExecApprovalsAgent>();
+            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<string, ExecApprovalsAgent>(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<ExecAllowlistEntry>();
-        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<ExecAllowlistEntry> NormalizeAllowlistEntries(
-        IEnumerable<ExecAllowlistEntry> entries, bool dropInvalid)
+        IEnumerable<ExecAllowlistEntry> entries,
+        bool dropInvalid)
     {
         var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
         var result = new List<ExecAllowlistEntry>();
         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<ExecAllowlistEntry>();
-        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<ReadOnlySnapshotLoadResult> 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<ExecApprovalsChangedEventArgs> 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<string, ExecApprovalsAgent>? CloneAgents(
+        Dictionary<string, ExecApprovalsAgent>? 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<object?> 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<Type, Type>
         {
             [typeof(Pages.SettingsPage)] = typeof(SettingsPageViewModel),
+            [typeof(Pages.PermissionsPage)] = typeof(PermissionsPageViewModel),
         };
 
     /// <summary>The root service provider, or null before startup / after shutdown.</summary>
@@ -137,6 +138,7 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands
     /// </summary>
     public event EventHandler? SettingsChanged;
     public event EventHandler? ChatProviderChanged;
+    private event EventHandler? PermissionsRuntimeChanged;
 
     /// <summary>
     /// Ensures the managed SSH tunnel is started using the current settings.
@@ -221,6 +223,8 @@ public IntPtr GetHubWindowHandle()
     private WeakReference<ToggleSwitch>? _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<string, Func<SettingsManager, bool>> 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<ISettingsEditor> edit,
+        Action<SettingsManager> 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<ToggleSwitch>(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<bool> 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<GatewayNodeInfo>();
+    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.
     /// </summary>
-    public async Task<bool> ApplyAutoStart(bool autoStart)
+    public async Task<bool> 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) =>
 
     /// <summary>
     /// 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.
     /// </summary>
     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"/>
                                     </Border>
                                     <Button Grid.Column="2"
-                                            Click="OnRemoveRule" Tag="{Binding Index}"
+                                            Click="OnRemoveRule" Tag="{Binding Rule}"
                                             Background="Transparent" BorderThickness="0"
                                             Padding="6" MinWidth="0"
                                             AutomationProperties.Name="{Binding RemoveRuleAutomationName}"
diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
index 41d53670c..366e639dc 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
@@ -1,226 +1,180 @@
 using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
 using Microsoft.UI.Xaml.Media;
-using OpenClaw.Connection;
 using OpenClaw.Shared;
-using OpenClaw.Shared.Audio;
-using OpenClaw.Shared.ExecApprovals;
 using OpenClawTray.Helpers;
-using OpenClawTray.Services;
-using OpenClawTray.Windows;
-using System;
-using System.Collections.Generic;
+using OpenClawTray.Presentation;
 using System.Diagnostics;
 using System.IO;
 using System.Linq;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Threading;
-using System.Threading.Tasks;
 
 namespace OpenClawTray.Pages;
 
 public sealed partial class PermissionsPage : Page
 {
-    private static App CurrentApp => (App)Microsoft.UI.Xaml.Application.Current!;
+    private PermissionsPageViewModel? _viewModel;
+    private readonly Dictionary<PermissionsCapabilityKey, ToggleSwitch> _featureToggles = new();
+    private bool _suppressNodeModeToggle;
     private bool _suppressMcpToggle;
-    private readonly List<ToggleSwitch> _featureToggles = new();
-    private List<ExecPolicyRule> _policyRules = new();
-    private string? _execPolicyBaseHash;
-    private enum ExecPolicyMutationKind { DefaultAction, AddRule, RemoveRule }
-    private sealed record ExecPolicyMutation(ExecPolicyMutationKind Kind, ExecPolicyRule? Rule = null);
-    private const int BrowserProxyToggleIndex = 1;
+    private bool _suppressDefaultActionChange;
+    private long _lastExecApprovalsStatusVersion;
+    private Microsoft.UI.Dispatching.DispatcherQueueTimer? _execSavedHintTimer;
 
     public PermissionsPage()
     {
         InitializeComponent();
-        Loaded += OnLoaded;
-        Unloaded += OnUnloaded;
-    }
-
-    public void Initialize()
-    {
         HostnameText.Text = Environment.MachineName;
-
-        BindNodeModeMaster();
         BuildCapabilityToggles();
-        UpdateMcpStatus();
-        UpdateVoiceSettingsCard();
-        UpdateNodeStatus();
-        ApplyFeaturesEnabledState();
-
-        LoadExecPolicy();
-        LoadAllowlist(CurrentApp.AppState?.Config);
+        DataContextChanged += OnDataContextChanged;
+        Unloaded += OnUnloaded;
     }
 
-    private void OnLoaded(object sender, RoutedEventArgs e)
+    private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
     {
-        if (CurrentApp.Settings != null)
-            CurrentApp.Settings.Saved += OnSettingsSaved;
+        if (_viewModel != null)
+        {
+            _viewModel.PropertyChanged -= OnViewModelPropertyChanged;
+        }
+
+        _viewModel = args.NewValue as PermissionsPageViewModel;
+        if (_viewModel != null)
+        {
+            _viewModel.PropertyChanged += OnViewModelPropertyChanged;
+        }
 
-        var mgr = CurrentApp.ConnectionManager;
-        if (mgr != null)
-            mgr.StateChanged += OnConnectionStateChanged;
+        RefreshFromViewModel();
     }
 
     private void OnUnloaded(object sender, RoutedEventArgs e)
     {
-        if (CurrentApp.Settings != null)
-            CurrentApp.Settings.Saved -= OnSettingsSaved;
-
-        var mgr = CurrentApp.ConnectionManager;
-        if (mgr != null)
-            mgr.StateChanged -= OnConnectionStateChanged;
+        if (_viewModel != null)
+        {
+            _viewModel.PropertyChanged -= OnViewModelPropertyChanged;
+        }
     }
 
-    private void OnConnectionStateChanged(object? sender, GatewayConnectionSnapshot snapshot)
+    private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
     {
-        DispatcherQueue?.TryEnqueue(() =>
+        switch (e.PropertyName)
         {
-            if (!IsLoaded) return;
-            UpdateNodeStatus();
-        });
+            case nameof(PermissionsPageViewModel.NodeModeEnabled):
+                UpdateNodeModeToggle();
+                break;
+            case nameof(PermissionsPageViewModel.Capabilities):
+            case nameof(PermissionsPageViewModel.AreFeaturesEnabled):
+            case nameof(PermissionsPageViewModel.FeaturesDescriptionResourceKey):
+                UpdateCapabilityToggles();
+                break;
+            case nameof(PermissionsPageViewModel.VoiceSettingsVisible):
+            case nameof(PermissionsPageViewModel.VoiceSetupRequirement):
+            case nameof(PermissionsPageViewModel.VoiceSetupHelpResourceKey):
+                UpdateVoiceSettingsCard();
+                break;
+            case nameof(PermissionsPageViewModel.NodeStatusKind):
+            case nameof(PermissionsPageViewModel.NodeStatusResourceKey):
+            case nameof(PermissionsPageViewModel.NodeDetailsResourceKey):
+            case nameof(PermissionsPageViewModel.NodeDetailsErrorText):
+            case nameof(PermissionsPageViewModel.McpServedCapabilityCount):
+            case nameof(PermissionsPageViewModel.LocalNodeCapabilities):
+            case nameof(PermissionsPageViewModel.LocalNodeCapabilityCount):
+                UpdateNodeStatus();
+                break;
+            case nameof(PermissionsPageViewModel.McpEnabled):
+            case nameof(PermissionsPageViewModel.McpEndpoint):
+            case nameof(PermissionsPageViewModel.McpStatusResourceKey):
+            case nameof(PermissionsPageViewModel.McpStatusErrorText):
+                UpdateMcpStatus();
+                break;
+            case nameof(PermissionsPageViewModel.DefaultExecActionTag):
+            case nameof(PermissionsPageViewModel.ExecApprovalRules):
+            case nameof(PermissionsPageViewModel.ExecApprovalsStatusVersion):
+                UpdateExecApprovals();
+                break;
+            case nameof(PermissionsPageViewModel.AllowCommands):
+            case nameof(PermissionsPageViewModel.GatewayAllowlistState):
+                UpdateAllowlist();
+                break;
+            case null:
+            case "":
+                RefreshFromViewModel();
+                break;
+        }
     }
 
-    private bool _suppressNodeModeToggle;
-
-    private void BindNodeModeMaster()
+    private void RefreshFromViewModel()
     {
-        if (CurrentApp.Settings == null) return;
-        _suppressNodeModeToggle = true;
-        NodeModeToggle.IsOn = CurrentApp.Settings.EnableNodeMode;
-        _suppressNodeModeToggle = false;
-    }
+        if (_viewModel is null)
+        {
+            return;
+        }
 
-    private void OnNodeModeToggled(object sender, RoutedEventArgs e)
-    {
-        if (_suppressNodeModeToggle || CurrentApp.Settings == null) return;
-        CurrentApp.Settings.EnableNodeMode = NodeModeToggle.IsOn;
-        CurrentApp.Settings.Save();
-        ((IAppCommands)CurrentApp).NotifySettingsSaved();
-        ApplyFeaturesEnabledState();
-        UpdateNodeStatus();
+        UpdateNodeModeToggle();
+        UpdateCapabilityToggles();
         UpdateVoiceSettingsCard();
+        UpdateNodeStatus();
+        UpdateMcpStatus();
+        UpdateExecApprovals();
+        UpdateAllowlist();
     }
 
-    private void OnSettingsSaved(object? sender, EventArgs e)
-    {
-        DispatcherQueue?.TryEnqueue(() =>
-        {
-            if (!IsLoaded) return;
-            BindNodeModeMaster();
-            ApplyFeaturesEnabledState();
-            UpdateNodeStatus();
-            ReloadFeatureToggleStates();
-            UpdateMcpStatus();
-            UpdateVoiceSettingsCard();
-        });
-    }
-
-    private void ReloadFeatureToggleStates()
+    private void UpdateNodeModeToggle()
     {
-        if (CurrentApp.Settings == null || _featureToggles.Count == 0) return;
-        var s = CurrentApp.Settings;
-        // Order matches BuildCapabilityToggles: system-run, browser, camera, canvas, screen, location, tts, stt.
-        bool[] expected =
-        {
-            s.NodeSystemRunEnabled,
-            s.NodeBrowserProxyEnabled, s.NodeCameraEnabled, s.NodeCanvasEnabled,
-            s.NodeScreenEnabled, s.NodeLocationEnabled, s.NodeTtsEnabled, s.NodeSttEnabled,
-        };
-        for (int i = 0; i < _featureToggles.Count && i < expected.Length; i++)
+        if (_viewModel is null)
         {
-            if (_featureToggles[i].IsOn != expected[i])
-                _featureToggles[i].IsOn = expected[i];
+            return;
         }
+
+        _suppressNodeModeToggle = true;
+        NodeModeToggle.IsOn = _viewModel.NodeModeEnabled;
+        _suppressNodeModeToggle = false;
     }
 
-    /// <summary>Enables capability toggles whenever either node transport can serve them.</summary>
-    private void ApplyFeaturesEnabledState()
+    private void OnNodeModeToggled(object sender, RoutedEventArgs e)
     {
-        var s = CurrentApp.Settings;
-        var canServe = (s?.EnableNodeMode ?? false) || (s?.EnableMcpServer ?? false);
-        CapabilityRepeater.Opacity = canServe ? 1.0 : 0.4;
-        for (int i = 0; i < _featureToggles.Count; i++)
+        if (_suppressNodeModeToggle || _viewModel is null)
         {
-            var isBrowserProxyToggle = i == BrowserProxyToggleIndex;
-            _featureToggles[i].IsEnabled = canServe && (!isBrowserProxyToggle || s?.EnableNodeMode == true);
+            return;
         }
-        FeaturesSectionDescription.Text = LocalizationHelper.GetString(canServe
-            ? "PermissionsPage_FeaturesDescription_Enabled"
-            : "PermissionsPage_FeaturesDescription_Disabled");
+
+        _viewModel.NodeModeEnabled = NodeModeToggle.IsOn;
     }
 
     private void BuildCapabilityToggles()
     {
-        if (CurrentApp.Settings == null) return;
-        var settings = CurrentApp.Settings;
-
-        var capabilities = new (string Icon, string Label, string Description, bool Value, Action<bool> Setter)[]
-        {
-            ("⚡",
-                LocalizationHelper.GetString("PermissionsPage_Cap_SystemRun_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_SystemRun_Description"),
-                settings.NodeSystemRunEnabled, v => settings.NodeSystemRunEnabled = v),
-            ("🌐",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Browser_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Browser_Description"),
-                settings.NodeBrowserProxyEnabled, v => settings.NodeBrowserProxyEnabled = v),
-            ("📷",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Camera_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Camera_Description"),
-                settings.NodeCameraEnabled, v => settings.NodeCameraEnabled = v),
-            ("🎨",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Canvas_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Canvas_Description"),
-                settings.NodeCanvasEnabled, v => settings.NodeCanvasEnabled = v),
-            ("🖥️",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Screen_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Screen_Description"),
-                settings.NodeScreenEnabled, v => settings.NodeScreenEnabled = v),
-            ("📍",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Location_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Location_Description"),
-                settings.NodeLocationEnabled, v => settings.NodeLocationEnabled = v),
-            ("🔊",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Tts_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Tts_Description"),
-                settings.NodeTtsEnabled, v => settings.NodeTtsEnabled = v),
-            ("🎤",
-                LocalizationHelper.GetString("PermissionsPage_Cap_Stt_Label"),
-                LocalizationHelper.GetString("PermissionsPage_Cap_Stt_Description"),
-                settings.NodeSttEnabled, v => settings.NodeSttEnabled = v),
+        CapabilityRepeater.ItemsSource = new[]
+        {
+            BuildCapabilityRow(PermissionsCapabilityKey.SystemRun, "⚡", "PermissionsPage_Cap_SystemRun_Label", "PermissionsPage_Cap_SystemRun_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.BrowserProxy, "🌐", "PermissionsPage_Cap_Browser_Label", "PermissionsPage_Cap_Browser_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.Camera, "📷", "PermissionsPage_Cap_Camera_Label", "PermissionsPage_Cap_Camera_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.Canvas, "🎨", "PermissionsPage_Cap_Canvas_Label", "PermissionsPage_Cap_Canvas_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.Screen, "🖥️", "PermissionsPage_Cap_Screen_Label", "PermissionsPage_Cap_Screen_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.Location, "📍", "PermissionsPage_Cap_Location_Label", "PermissionsPage_Cap_Location_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.TextToSpeech, "🔊", "PermissionsPage_Cap_Tts_Label", "PermissionsPage_Cap_Tts_Description"),
+            BuildCapabilityRow(PermissionsCapabilityKey.SpeechToText, "🎤", "PermissionsPage_Cap_Stt_Label", "PermissionsPage_Cap_Stt_Description"),
         };
+    }
 
-        var items = new List<UIElement>();
-        _featureToggles.Clear();
-        foreach (var (icon, label, description, value, setter) in capabilities)
+    private Border BuildCapabilityRow(PermissionsCapabilityKey key, string icon, string labelKey, string descriptionKey)
+    {
+        var label = LocalizationHelper.GetString(labelKey);
+        var toggle = new ToggleSwitch
         {
-            var toggle = new ToggleSwitch
-            {
-                IsOn = value,
-                MinWidth = 0,
-                OnContent = "",
-                OffContent = "",
-            };
-            Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(toggle, label);
-            toggle.Toggled += (s, e) =>
+            MinWidth = 0,
+            OnContent = string.Empty,
+            OffContent = string.Empty,
+        };
+        Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(toggle, label);
+        toggle.Toggled += (_, _) =>
+        {
+            if (_viewModel is null)
             {
-                setter(toggle.IsOn);
-                settings.Save();
-                ((IAppCommands)CurrentApp).NotifySettingsSaved();
-                UpdateVoiceSettingsCard();
-                UpdateNodeStatus();
-            };
-            _featureToggles.Add(toggle);
-            items.Add(BuildCapabilityRow(icon, label, description, toggle));
-        }
+                return;
+            }
 
-        CapabilityRepeater.ItemsSource = items;
-    }
+            _viewModel.SetCapabilityEnabled(key, toggle.IsOn);
+        };
+        _featureToggles[key] = toggle;
 
-    private static Border BuildCapabilityRow(string icon, string label, string description, ToggleSwitch toggle)
-    {
         var grid = new Grid { ColumnSpacing = 14 };
         grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(36) });
         grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
@@ -244,7 +198,7 @@ private static Border BuildCapabilityRow(string icon, string label, string descr
         });
         text.Children.Add(new TextBlock
         {
-            Text = description,
+            Text = LocalizationHelper.GetString(descriptionKey),
             Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
             Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
             TextWrapping = TextWrapping.Wrap,
@@ -266,211 +220,141 @@ private static Border BuildCapabilityRow(string icon, string label, string descr
         };
     }
 
-    // ── Voice settings link ──────────────────────────────────────────
-
-    private void UpdateVoiceSettingsCard()
+    private void UpdateCapabilityToggles()
     {
-        var settings = CurrentApp.Settings;
-        var enabled = settings?.NodeSttEnabled == true || settings?.NodeTtsEnabled == true;
-        var setupRequirement = settings == null
-            ? VoiceSetupRequirement.None
-            : GetVoiceSetupRequirement(settings);
-
-        VoiceSettingsCard.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed;
-        VoiceSettingsHelpPanel.Visibility = setupRequirement != VoiceSetupRequirement.None
-            ? Visibility.Visible
-            : Visibility.Collapsed;
-        VoiceSettingsHelpText.Text = GetVoiceSetupRequirementText(setupRequirement);
-    }
-
-    private enum VoiceSetupRequirement
-    {
-        None,
-        SpeechModel,
-        VoiceSetup,
-        SpeechModelAndVoiceSetup
-    }
-
-    private static VoiceSetupRequirement GetVoiceSetupRequirement(SettingsManager settings)
-    {
-        var needsSpeechModel = settings.NodeSttEnabled && !IsConfiguredWhisperModelDownloaded(settings);
-        var needsVoiceSetup = settings.NodeTtsEnabled && SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired(settings);
-
-        return (needsSpeechModel, needsVoiceSetup) switch
+        if (_viewModel is null)
         {
-            (true, true) => VoiceSetupRequirement.SpeechModelAndVoiceSetup,
-            (true, false) => VoiceSetupRequirement.SpeechModel,
-            (false, true) => VoiceSetupRequirement.VoiceSetup,
-            _ => VoiceSetupRequirement.None
-        };
-    }
+            return;
+        }
 
-    private static string GetVoiceSetupRequirementText(VoiceSetupRequirement requirement)
-    {
-        var key = requirement switch
+        CapabilityRepeater.Opacity = _viewModel.AreFeaturesEnabled ? 1.0 : 0.4;
+        FeaturesSectionDescription.Text = LocalizationHelper.GetString(_viewModel.FeaturesDescriptionResourceKey);
+        foreach (var capability in _viewModel.Capabilities)
         {
-            VoiceSetupRequirement.SpeechModel => "PermissionsPage_VoiceSettingsHelp_SpeechModel",
-            VoiceSetupRequirement.VoiceSetup => "PermissionsPage_VoiceSettingsHelp_VoiceSetup",
-            VoiceSetupRequirement.SpeechModelAndVoiceSetup => "PermissionsPage_VoiceSettingsHelp_Both",
-            _ => ""
-        };
+            if (!_featureToggles.TryGetValue(capability.Key, out var toggle))
+            {
+                continue;
+            }
 
-        return string.IsNullOrEmpty(key) ? "" : LocalizationHelper.GetString(key);
+            if (toggle.IsOn != capability.IsOn)
+            {
+                toggle.IsOn = capability.IsOn;
+            }
+
+            toggle.IsEnabled = capability.IsInteractive;
+        }
     }
 
-    private static bool IsConfiguredWhisperModelDownloaded(SettingsManager settings)
+    private void UpdateVoiceSettingsCard()
     {
-        var modelName = settings.SttModelName;
-        if (!WhisperModelManager.AvailableModels.Any(m =>
-                string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase)))
+        if (_viewModel is null)
         {
-            return false;
+            return;
         }
 
-        var manager = new WhisperModelManager(SettingsManager.SettingsDirectoryPath, new AppLogger());
-        return manager.IsModelDownloaded(modelName);
+        VoiceSettingsCard.Visibility = _viewModel.VoiceSettingsVisible ? Visibility.Visible : Visibility.Collapsed;
+        VoiceSettingsHelpPanel.Visibility = _viewModel.VoiceSetupRequirement != PermissionsVoiceSetupRequirement.None
+            ? Visibility.Visible
+            : Visibility.Collapsed;
+        VoiceSettingsHelpText.Text = string.IsNullOrWhiteSpace(_viewModel.VoiceSetupHelpResourceKey)
+            ? string.Empty
+            : LocalizationHelper.GetString(_viewModel.VoiceSetupHelpResourceKey);
     }
 
     private void OnVoiceSettingsClick(object sender, RoutedEventArgs e)
     {
-        ((IAppCommands)CurrentApp).Navigate("voice");
+        ((Services.IAppCommands)Application.Current).Navigate("voice");
     }
 
-    // ── Node status ──────────────────────────────────────────────────
-
     private void UpdateNodeStatus()
     {
-        var settings = CurrentApp.Settings;
-        var nodeEnabled = settings?.EnableNodeMode ?? false;
-        var mcpEnabled = settings?.EnableMcpServer ?? false;
-
-        if (!nodeEnabled)
+        if (_viewModel is null)
         {
-            if (mcpEnabled && settings != null)
-            {
-                var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
-                if (!string.IsNullOrEmpty(mcpError))
-                {
-                    NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed);
-                    NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError");
-                    NodeDetailsText.Text = mcpError;
-                }
-                else
-                {
-                    NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.DodgerBlue);
-                    NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpOnly");
-                    NodeDetailsText.Text = LocalizationHelper.Format(
-                        "PermissionsPage_NodeStatus_McpOnlyDetailsFormat",
-                        NodeCapabilityGating.CountMcpServedCapabilities(settings),
-                        NodeService.McpServerUrl);
-                }
-            }
-            else
-            {
-                NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray);
-                NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Disabled");
-                NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_DisabledDetails");
-            }
             return;
         }
 
-        var snap = CurrentApp.ConnectionManager?.CurrentSnapshot;
-        var nodeState = snap?.NodeState ?? RoleConnectionState.Idle;
-        var operatorConnected = snap?.OperatorState == RoleConnectionState.Connected;
-        var mcpStartupError = CurrentApp.ActiveNodeService?.McpStartupError;
+        NodeStatusDot.Fill = new SolidColorBrush(_viewModel.NodeStatusKind switch
+        {
+            PermissionsNodeStatusKind.Disabled => Microsoft.UI.Colors.Gray,
+            PermissionsNodeStatusKind.McpOnly => Microsoft.UI.Colors.DodgerBlue,
+            PermissionsNodeStatusKind.McpError => Microsoft.UI.Colors.OrangeRed,
+            PermissionsNodeStatusKind.Active => Microsoft.UI.Colors.LimeGreen,
+            PermissionsNodeStatusKind.Starting => Microsoft.UI.Colors.Goldenrod,
+            _ => Microsoft.UI.Colors.Orange,
+        });
+        NodeStatusText.Text = LocalizationHelper.GetString(_viewModel.NodeStatusResourceKey);
+        NodeDetailsText.Text = BuildNodeDetailsText();
+    }
 
-        if (mcpEnabled && !string.IsNullOrEmpty(mcpStartupError))
+    private string BuildNodeDetailsText()
+    {
+        if (_viewModel is null)
         {
-            NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed);
-            NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError");
-            NodeDetailsText.Text = mcpStartupError;
-        }
-        else if (nodeState == RoleConnectionState.Connected && operatorConnected)
-        {
-            NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.LimeGreen);
-            NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Active");
-
-            // Read capability list from GatewayNodeInfo — same source of truth
-            // used by the tray menu, instances page, and connection page.
-            var caps = NodeCapabilityGating.GetLocalNodeCapabilities(
-                CurrentApp.AppState?.Nodes, CurrentApp.NodeFullDeviceId);
-            NodeDetailsText.Text = caps != null && caps.Count > 0
-                ? LocalizationHelper.Format(
-                    "PermissionsPage_NodeStatus_ActiveDetailsFormat",
-                    caps.Count, string.Join(", ", caps))
-                : LocalizationHelper.GetString("PermissionsPage_NodeStatus_NoCapabilities");
+            return string.Empty;
         }
-        else if (nodeState == RoleConnectionState.Connecting)
+
+        if (!string.IsNullOrWhiteSpace(_viewModel.NodeDetailsErrorText))
         {
-            NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Goldenrod);
-            NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Starting");
-            NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails");
-        }
-        else
-        {
-            NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Orange);
-            NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnected");
-            NodeDetailsText.Text = mcpEnabled && settings != null && string.IsNullOrEmpty(mcpStartupError)
-                ? LocalizationHelper.Format(
-                    "PermissionsPage_NodeStatus_McpOnlyDetailsFormat",
-                    NodeCapabilityGating.CountMcpServedCapabilities(settings),
-                    NodeService.McpServerUrl)
-                : LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails");
+            return _viewModel.NodeDetailsErrorText;
         }
-    }
 
-    // ── MCP server ───────────────────────────────────────────────────
+        return _viewModel.NodeDetailsResourceKey switch
+        {
+            "PermissionsPage_NodeStatus_McpOnlyDetailsFormat" => LocalizationHelper.Format(
+                _viewModel.NodeDetailsResourceKey,
+                _viewModel.McpServedCapabilityCount,
+                _viewModel.McpEndpoint),
+            "PermissionsPage_NodeStatus_ActiveDetailsFormat" => LocalizationHelper.Format(
+                _viewModel.NodeDetailsResourceKey,
+                _viewModel.LocalNodeCapabilityCount,
+                string.Join(", ", _viewModel.LocalNodeCapabilities)),
+            null => string.Empty,
+            _ => LocalizationHelper.GetString(_viewModel.NodeDetailsResourceKey),
+        };
+    }
 
     private void UpdateMcpStatus()
     {
-        var settings = CurrentApp.Settings;
-        if (settings == null) return;
+        if (_viewModel is null)
+        {
+            return;
+        }
 
         _suppressMcpToggle = true;
-        McpToggle.IsOn = settings.EnableMcpServer;
+        McpToggle.IsOn = _viewModel.McpEnabled;
         _suppressMcpToggle = false;
-        McpDetailsPanel.Visibility = settings.EnableMcpServer ? Visibility.Visible : Visibility.Collapsed;
-        McpEndpointText.Text = NodeService.McpServerUrl;
+        McpDetailsPanel.Visibility = _viewModel.McpEnabled ? Visibility.Visible : Visibility.Collapsed;
+        McpEndpointText.Text = _viewModel.McpEndpoint;
 
-        if (settings.EnableMcpServer)
+        if (!_viewModel.McpEnabled)
         {
-            var mcpError = CurrentApp.ActiveNodeService?.McpStartupError;
-            if (!string.IsNullOrEmpty(mcpError))
-            {
-                McpStatusText.Text =
-                    $"{LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError")}: {mcpError}";
-                return;
-            }
-
-            var tokenPath = NodeService.McpTokenPath;
-            var tokenExists = File.Exists(tokenPath);
-            McpStatusText.Text = LocalizationHelper.GetString(tokenExists
-                ? "PermissionsPage_McpStatus_TokenReady"
-                : "PermissionsPage_McpStatus_TokenPending");
+            McpStatusText.Text = string.Empty;
+            return;
         }
+
+        McpStatusText.Text = !string.IsNullOrWhiteSpace(_viewModel.McpStatusErrorText)
+            ? $"{LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError")}: {_viewModel.McpStatusErrorText}"
+            : LocalizationHelper.GetString(_viewModel.McpStatusResourceKey);
     }
 
     private void OnMcpToggled(object sender, RoutedEventArgs e)
     {
-        if (_suppressMcpToggle) return;
-        if (CurrentApp.Settings == null) return;
-        CurrentApp.Settings.EnableMcpServer = McpToggle.IsOn;
-        CurrentApp.Settings.Save();
-        ((IAppCommands)CurrentApp).NotifySettingsSaved();
-        UpdateMcpStatus();
-        UpdateNodeStatus();
-        ApplyFeaturesEnabledState();
+        if (_suppressMcpToggle || _viewModel is null)
+        {
+            return;
+        }
+
+        _viewModel.McpEnabled = McpToggle.IsOn;
     }
 
     private void OnCopyMcpToken(object sender, RoutedEventArgs e)
     {
         try
         {
-            var tokenPath = NodeService.McpTokenPath;
+            var tokenPath = Services.NodeService.McpTokenPath;
             if (File.Exists(tokenPath))
             {
-                var token = File.ReadAllText(tokenPath).Trim();
-                ClipboardHelper.CopyText(token);
+                ClipboardHelper.CopyText(File.ReadAllText(tokenPath).Trim());
                 McpStatusText.Text = LocalizationHelper.GetString("PermissionsPage_McpStatus_TokenCopied");
             }
             else
@@ -480,388 +364,172 @@ private void OnCopyMcpToken(object sender, RoutedEventArgs e)
         }
         catch (Exception ex)
         {
-            McpStatusText.Text = LocalizationHelper.Format(
-                "PermissionsPage_McpStatus_TokenReadFailedFormat", ex.Message);
+            McpStatusText.Text = LocalizationHelper.Format("PermissionsPage_McpStatus_TokenReadFailedFormat", ex.Message);
         }
     }
 
     private void OnCopyMcpUrl(object sender, RoutedEventArgs e)
     {
-        ClipboardHelper.CopyText(NodeService.McpServerUrl);
+        ClipboardHelper.CopyText(Services.NodeService.McpServerUrl);
         McpStatusText.Text = LocalizationHelper.GetString("PermissionsPage_McpStatus_UrlCopied");
     }
 
-    // ── Exec approvals ───────────────────────────────────────────────
-
-    private void LoadExecPolicy() => _ = LoadExecPolicyAsync();
-
-    private async Task LoadExecPolicyAsync()
+    private void UpdateExecApprovals()
     {
-        _loadingExecPolicy = true;
-        try
-        {
-            var snapshot = await CurrentApp.ExecApprovalsStore.GetSnapshotAsync();
-            _execPolicyBaseHash = snapshot.Hash;
-            var file = snapshot.File;
-            var defaults = file.Defaults;
-            ExecApprovalsAgent? main = null;
-            file.Agents?.TryGetValue("main", out main);
-            var security = main?.Security ?? defaults?.Security ?? ExecSecurity.Deny;
-            var ask = main?.Ask ?? defaults?.Ask ?? ExecAsk.OnMiss;
-            var action = security switch
-            {
-                ExecSecurity.Full => "allow",
-                ExecSecurity.Allowlist when ask is ExecAsk.OnMiss or ExecAsk.Always => "prompt",
-                _ => "deny",
-            };
-            for (int i = 0; i < DefaultActionCombo.Items.Count; i++)
-            {
-                if (DefaultActionCombo.Items[i] is ComboBoxItem item && item.Tag?.ToString() == action)
-                { DefaultActionCombo.SelectedIndex = i; break; }
-            }
-
-            RefreshPolicyRulesFromFile(file);
-        }
-        catch (Exception ex)
+        if (_viewModel is null)
         {
-            Debug.WriteLine($"Failed to load exec approvals: {ex.Message}");
-            DefaultActionCombo.SelectedIndex = 0;
-            RefreshPolicyRulesList();
+            return;
         }
-        finally { _loadingExecPolicy = false; }
-    }
 
-    private void RefreshPolicyRulesList()
-    {
-        for (int i = 0; i < _policyRules.Count; i++) _policyRules[i].Index = i;
-        var allowBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SystemFillColorSuccessBrush"];
-        PolicyRulesList.ItemsSource = null;
-        PolicyRulesList.ItemsSource = _policyRules.Select(r => new
-        {
-            r.Pattern,
-            Action = DisplayExecPolicyAction(r.Action),
-            r.Index,
-            RemoveRuleAutomationName = $"Remove rule {r.Pattern}",
-            RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{r.Index}",
-            ActionBrush = allowBrush
+        _suppressDefaultActionChange = true;
+        SelectComboBoxTag(DefaultActionCombo, _viewModel.DefaultExecActionTag);
+        _suppressDefaultActionChange = false;
+
+        PolicyRulesList.ItemsSource = _viewModel.ExecApprovalRules.Select((rule, index) => new
+        {
+            Rule = rule,
+            rule.Pattern,
+            RemoveRuleAutomationName = $"Remove rule {rule.Pattern}",
+            RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{index}",
+            Action = "allow",
+            ActionBrush = (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"],
         }).ToList();
 
-        // Header badge + empty state
-        var count = _policyRules.Count;
+        var count = _viewModel.ExecApprovalRules.Count;
         RulesCountBadge.Text = count switch
         {
             0 => LocalizationHelper.GetString("PermissionsPage_RulesCount_None"),
             1 => LocalizationHelper.GetString("PermissionsPage_RulesCount_One"),
-            _ => LocalizationHelper.Format("PermissionsPage_RulesCount_ManyFormat", count)
+            _ => LocalizationHelper.Format("PermissionsPage_RulesCount_ManyFormat", count),
         };
         RulesEmptyState.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed;
         PolicyRulesList.Visibility = count == 0 ? Visibility.Collapsed : Visibility.Visible;
-    }
 
-    private void OnAddRule(object sender, RoutedEventArgs e)
-    {
-        var pattern = NewRulePattern.Text.Trim();
-        if (string.IsNullOrEmpty(pattern)) return;
-        if (!ExecApprovalsStore.IsValidAllowlistPattern(pattern))
+        if (_viewModel.ExecApprovalsStatusVersion != _lastExecApprovalsStatusVersion)
         {
-            NewRulePattern.Focus(FocusState.Programmatic);
-            return;
+            _lastExecApprovalsStatusVersion = _viewModel.ExecApprovalsStatusVersion;
+            ShowExecPolicySaveStatus(_viewModel.ExecApprovalsStatus);
         }
-        ExecPolicyRuleList.UpsertByPattern(_policyRules, pattern, "allow");
-        var rule = CloneExecPolicyRule(_policyRules.First(r =>
-            string.Equals(r.Pattern, pattern, StringComparison.OrdinalIgnoreCase)));
-        NewRulePattern.Text = "";
-        RefreshPolicyRulesList();
-        SaveExecPolicyToDisk(new ExecPolicyMutation(ExecPolicyMutationKind.AddRule, rule));
     }
 
-    private void OnRemoveRule(object sender, RoutedEventArgs e)
+    private static void SelectComboBoxTag(ComboBox comboBox, string tag)
     {
-        if (sender is Button btn && btn.Tag is int index && index < _policyRules.Count)
+        for (var i = 0; i < comboBox.Items.Count; i++)
         {
-            var removed = CloneExecPolicyRule(_policyRules[index]);
-            _policyRules.RemoveAt(index);
-            RefreshPolicyRulesList();
-            SaveExecPolicyToDisk(new ExecPolicyMutation(ExecPolicyMutationKind.RemoveRule, removed));
+            if (comboBox.Items[i] is ComboBoxItem item && string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase))
+            {
+                comboBox.SelectedIndex = i;
+                return;
+            }
         }
-    }
 
-    private void OnDefaultActionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        // Skip the selection-changed events that fire while LoadExecPolicy is populating the combo.
-        if (!_loadingExecPolicy)
-            SaveExecPolicyToDisk(new ExecPolicyMutation(ExecPolicyMutationKind.DefaultAction));
+        comboBox.SelectedIndex = 0;
     }
 
-    private bool _loadingExecPolicy;
-    private Microsoft.UI.Dispatching.DispatcherQueueTimer? _execSavedHintTimer;
-
-    private void SaveExecPolicyToDisk(
-        ExecPolicyMutation mutation,
-        bool showSavedHint = true) =>
-        _ = SaveExecPolicyToDiskAsync(mutation, showSavedHint);
+    private void OnAddRule(object sender, RoutedEventArgs e) =>
+        AsyncEventHandlerGuard.Run(OnAddRuleAsync, new AppLogger(), nameof(OnAddRule));
 
-    private async Task SaveExecPolicyToDiskAsync(
-        ExecPolicyMutation mutation,
-        bool showSavedHint)
+    private async Task OnAddRuleAsync()
     {
-        try
-        {
-            var defaultAction = mutation.Kind == ExecPolicyMutationKind.DefaultAction
-                ? NormalizeExecPolicyAction(
-                    (DefaultActionCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString())
-                : null;
-            for (var attempt = 0; attempt < 3; attempt++)
-            {
-                var current = await CurrentApp.ExecApprovalsStore.GetSnapshotAsync();
-                var expectedHash = attempt == 0 && !string.IsNullOrWhiteSpace(_execPolicyBaseHash)
-                    ? _execPolicyBaseHash
-                    : current.Hash;
-                var file = current.File;
-                file.Version = 1;
-                file.Defaults ??= new ExecApprovalsDefaults();
-                file.Agents ??= new Dictionary<string, ExecApprovalsAgent>(StringComparer.Ordinal);
-                if (!file.Agents.TryGetValue("main", out var main))
-                {
-                    main = new ExecApprovalsAgent();
-                    file.Agents["main"] = main;
-                }
-
-                if (mutation.Kind == ExecPolicyMutationKind.DefaultAction)
-                {
-                    var (security, ask) = defaultAction switch
-                    {
-                        "allow" => (ExecSecurity.Full, ExecAsk.Off),
-                        "prompt" => (ExecSecurity.Allowlist, ExecAsk.OnMiss),
-                        _ => (ExecSecurity.Allowlist, ExecAsk.Off),
-                    };
-                    file.Defaults.Security = security;
-                    file.Defaults.Ask = ask;
-                    file.Defaults.AskFallback = ExecSecurity.Deny;
-                    file.Defaults.AutoAllowSkills ??= false;
-                    main.Security = security;
-                    main.Ask = ask;
-                    main.AskFallback = ExecSecurity.Deny;
-                    main.AutoAllowSkills ??= false;
-                }
-                else if (mutation is { Kind: ExecPolicyMutationKind.AddRule, Rule: { } added })
-                {
-                    var allowlist = main.Allowlist ??= [];
-                    if (!allowlist.Any(entry => string.Equals(
-                            entry.Pattern?.Trim(),
-                            added.Pattern.Trim(),
-                            StringComparison.OrdinalIgnoreCase)))
-                    {
-                        allowlist.Add(new ExecAllowlistEntry
-                        {
-                            Id = added.Id ?? Guid.NewGuid(),
-                            Pattern = added.Pattern,
-                            LastUsedAt = added.LastUsedAt,
-                            LastResolvedPath = added.LastResolvedPath,
-                        });
-                    }
-                }
-                else if (mutation is { Kind: ExecPolicyMutationKind.RemoveRule, Rule: { } removed })
-                {
-                    main.Allowlist?.RemoveAll(entry =>
-                        (removed.Id.HasValue && entry.Id == removed.Id)
-                        || string.Equals(
-                            entry.Pattern?.Trim(),
-                            removed.Pattern.Trim(),
-                            StringComparison.OrdinalIgnoreCase));
-                }
-
-                var updated = await CurrentApp.ExecApprovalsStore.ReplaceAsync(expectedHash, file);
-                if (updated is null)
-                {
-                    _execPolicyBaseHash = current.Hash;
-                    continue;
-                }
-
-                _execPolicyBaseHash = updated.Hash;
-                RefreshPolicyRulesFromFile(updated.File);
-                if (!showSavedHint)
-                    return;
-
-                ShowExecPolicySaveStatus(succeeded: true);
-                return;
-            }
-
-            Debug.WriteLine("Failed to save exec approvals after concurrent updates.");
-            var latest = await CurrentApp.ExecApprovalsStore.GetSnapshotAsync();
-            _execPolicyBaseHash = latest.Hash;
-            RefreshPolicyRulesFromFile(latest.File);
-            ShowExecPolicySaveStatus(succeeded: false);
-        }
-        catch (Exception ex)
+        if (_viewModel is null
+            || !await _viewModel.TryAddExecApprovalRuleAsync(NewRulePattern.Text.Trim()))
         {
-            Debug.WriteLine($"Failed to save exec approvals: {ex.Message}");
-            ShowExecPolicySaveStatus(succeeded: false);
+            NewRulePattern.Focus(FocusState.Programmatic);
+            return;
         }
+
+        NewRulePattern.Text = string.Empty;
     }
 
-    private void ShowExecPolicySaveStatus(bool succeeded)
+    private void OnRemoveRule(object sender, RoutedEventArgs e)
     {
-        ExecPolicySavedHint.Text = LocalizationHelper.GetString(
-            succeeded
-                ? "PermissionsPage_ExecPolicySaved"
-                : "PermissionsPage_ExecPolicySaveFailed");
-        ExecPolicySavedHint.Visibility = Visibility.Visible;
-        if (_execSavedHintTimer == null)
+        if (sender is Button button && button.Tag is PermissionsExecApprovalRule rule)
         {
-            _execSavedHintTimer = DispatcherQueue.CreateTimer();
-            _execSavedHintTimer.Interval = TimeSpan.FromSeconds(1.5);
-            _execSavedHintTimer.Tick += (t, _) =>
-            {
-                ExecPolicySavedHint.Visibility = Visibility.Collapsed;
-                t.Stop();
-            };
+            AsyncEventHandlerGuard.Run(
+                () => RemoveRuleAsync(rule),
+                new AppLogger(),
+                nameof(OnRemoveRule));
         }
-        _execSavedHintTimer.Stop();
-        _execSavedHintTimer.Start();
     }
 
-    private void RefreshPolicyRulesFromFile(ExecApprovalsFile file)
+    private async Task RemoveRuleAsync(PermissionsExecApprovalRule rule)
     {
-        ExecApprovalsAgent? main = null;
-        file.Agents?.TryGetValue("main", out main);
-        _policyRules.Clear();
-        if (main?.Allowlist is { } allowlist)
+        if (_viewModel is not null)
         {
-            var index = 0;
-            foreach (var entry in allowlist)
-            {
-                _policyRules.Add(new ExecPolicyRule
-                {
-                    Id = entry.Id,
-                    Pattern = entry.Pattern ?? "",
-                    Action = "allow",
-                    LastUsedAt = entry.LastUsedAt,
-                    LastResolvedPath = entry.LastResolvedPath,
-                    Index = index++,
-                });
-            }
+            await _viewModel.RemoveExecApprovalRuleAsync(rule);
         }
-        RefreshPolicyRulesList();
     }
 
-    private static ExecPolicyRule CloneExecPolicyRule(ExecPolicyRule rule) =>
-        new()
-        {
-            Id = rule.Id,
-            Pattern = rule.Pattern,
-            Action = rule.Action,
-            LastUsedAt = rule.LastUsedAt,
-            LastResolvedPath = rule.LastResolvedPath,
-            Index = rule.Index,
-        };
-
-    private static string? TryGetStringCaseInsensitive(JsonElement element, params string[] names)
+    private void OnDefaultActionChanged(object sender, SelectionChangedEventArgs e)
     {
-        foreach (var name in names)
+        if (_suppressDefaultActionChange || _viewModel is null)
         {
-            if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
-                return prop.GetString();
+            return;
         }
-        return null;
-    }
-
-    internal static string NormalizeExecPolicyAction(string? action) =>
-        ExecPolicyRuleList.NormalizeAction(action);
 
-    private static string NormalizeExecPolicyAction(JsonElement action) =>
-        ExecPolicyRuleList.NormalizeAction(action);
+        var action = (DefaultActionCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "deny";
+        AsyncEventHandlerGuard.Run(
+            () => _viewModel.SetDefaultExecActionAsync(action),
+            new AppLogger(),
+            nameof(OnDefaultActionChanged));
+    }
 
-    private static string[]? TryGetStringArrayCaseInsensitive(JsonElement element, params string[] names)
+    private void ShowExecPolicySaveStatus(PermissionsExecApprovalsStatus status)
     {
-        foreach (var name in names)
+        if (status == PermissionsExecApprovalsStatus.None)
         {
-            if (!element.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Array)
-                continue;
-
-            var values = new List<string>();
-            foreach (var item in prop.EnumerateArray())
-            {
-                if (item.ValueKind == JsonValueKind.String)
-                    values.Add(item.GetString() ?? "");
-            }
-
-            return values.ToArray();
+            return;
         }
 
-        return null;
+        ExecPolicySavedHint.Text = LocalizationHelper.GetString(
+            status == PermissionsExecApprovalsStatus.Saved
+                ? "PermissionsPage_ExecPolicySaved"
+                : "PermissionsPage_ExecPolicySaveFailed");
+        ExecPolicySavedHint.Visibility = Visibility.Visible;
+        _execSavedHintTimer ??= CreateExecSavedHintTimer();
+        _execSavedHintTimer.Stop();
+        _execSavedHintTimer.Start();
     }
 
-    private static bool? TryGetBoolCaseInsensitive(JsonElement element, params string[] names)
+    private Microsoft.UI.Dispatching.DispatcherQueueTimer CreateExecSavedHintTimer()
     {
-        foreach (var name in names)
+        var timer = DispatcherQueue.CreateTimer();
+        timer.Interval = TimeSpan.FromSeconds(1.5);
+        timer.Tick += (sender, _) =>
         {
-            if (!element.TryGetProperty(name, out var prop))
-                continue;
-            if (prop.ValueKind == JsonValueKind.True) return true;
-            if (prop.ValueKind == JsonValueKind.False) return false;
-        }
-
-        return null;
+            ExecPolicySavedHint.Visibility = Visibility.Collapsed;
+            sender.Stop();
+        };
+        return timer;
     }
 
-    private static string DisplayExecPolicyAction(string action) =>
-        string.Equals(action, "prompt", StringComparison.OrdinalIgnoreCase) ? "ask" : action;
-
-    // ── Node Allowlist ───────────────────────────────────────────────
-
-    private void LoadAllowlist(JsonElement? config)
+    private void UpdateAllowlist()
     {
-        if (!config.HasValue)
+        if (_viewModel is null)
         {
-            AllowlistEmpty.Visibility = Visibility.Visible;
             return;
         }
-        UpdateAllowlist(config.Value);
-    }
 
-    public void UpdateAllowlist(JsonElement config)
-    {
-        DispatcherQueue?.TryEnqueue(() =>
+        if (_viewModel.GatewayAllowlistState != PermissionsGatewayAllowlistState.Commands)
         {
-            try
-            {
-                var commands = new List<string>();
-
-                if (config.TryGetProperty("gateway", out var gw) &&
-                    gw.TryGetProperty("nodes", out var nodes) &&
-                    nodes.TryGetProperty("allowCommands", out var ac) &&
-                    ac.ValueKind == JsonValueKind.Array)
+            AllowlistEmpty.Text = LocalizationHelper.GetString(
+                _viewModel.GatewayAllowlistState switch
                 {
-                    foreach (var cmd in ac.EnumerateArray())
-                    {
-                        var s = cmd.GetString();
-                        if (!string.IsNullOrWhiteSpace(s)) commands.Add(s);
-                    }
-                }
-
-                if (commands.Count == 0)
-                {
-                    AllowlistEmpty.Text = LocalizationHelper.GetString("PermissionsPage_Allowlist_NoCommands");
-                    AllowlistEmpty.Visibility = Visibility.Visible;
-                    AllowlistRepeater.ItemsSource = null;
-                    return;
-                }
-
-                AllowlistEmpty.Visibility = Visibility.Collapsed;
-                AllowlistRepeater.ItemsSource = commands.Select(cmd => CreateAllowlistTag(cmd)).ToList();
-            }
-            catch
-            {
-                AllowlistEmpty.Text = LocalizationHelper.GetString("PermissionsPage_Allowlist_ParseFailed");
-                AllowlistEmpty.Visibility = Visibility.Visible;
-            }
-        });
+                    PermissionsGatewayAllowlistState.NoConfig => "PermissionsPage_Allowlist_NoConfig",
+                    PermissionsGatewayAllowlistState.ParseFailed => "PermissionsPage_Allowlist_ParseFailed",
+                    _ => "PermissionsPage_Allowlist_NoCommands",
+                });
+            AllowlistEmpty.Visibility = Visibility.Visible;
+            AllowlistRepeater.ItemsSource = null;
+            return;
+        }
+
+        AllowlistEmpty.Visibility = Visibility.Collapsed;
+        AllowlistRepeater.ItemsSource = _viewModel.AllowCommands.Select(CreateAllowlistTag).ToList();
     }
 
-    private static Border CreateAllowlistTag(string command)
-    {
-        return new Border
+    private static Border CreateAllowlistTag(string command) =>
+        new()
         {
             CornerRadius = new CornerRadius(4),
             Padding = new Thickness(8, 4, 8, 4),
@@ -872,20 +540,18 @@ private static Border CreateAllowlistTag(string command)
                 Text = command,
                 FontSize = 12,
                 FontFamily = new FontFamily("Consolas"),
-                Foreground = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 255, 255, 255))
-            }
+                Foreground = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 255, 255, 255)),
+            },
         };
-    }
-
-    // ── Windows-level privacy ────────────────────────────────────────
 
     private void OnOpenPrivacySettings(object sender, RoutedEventArgs e)
     {
-        try { Process.Start(new ProcessStartInfo("ms-settings:privacy-webcam") { UseShellExecute = true }); }
-        // slopwatch-ignore: SW003 Diagnostic logging fallback is best-effort and logging failure must not cascade.
-        catch { }
+        try
+        {
+            Process.Start(new ProcessStartInfo("ms-settings:privacy-webcam") { UseShellExecute = true });
+        }
+        catch
+        {
+        }
     }
-
-    // ── Types ────────────────────────────────────────────────────────
-
 }
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs
index 3326a2ac7..f2d5cfea5 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceContext.cs
@@ -1,3 +1,4 @@
+using OpenClaw.Shared.ExecApprovals;
 using OpenClawTray.Services;
 
 namespace OpenClawTray.Presentation;
@@ -10,14 +11,23 @@ namespace OpenClawTray.Presentation;
 /// </summary>
 internal sealed class AppServiceContext
 {
-    public AppServiceContext(IUiDispatcher dispatcher, IAppCommands appCommands, SettingsManager settings)
+    public AppServiceContext(
+        IUiDispatcher dispatcher,
+        IAppCommands appCommands,
+        SettingsManager settings,
+        IExecApprovalsPresentationStore execApprovalsStore,
+        IPermissionsPageRuntimeHost permissionsRuntimeHost)
     {
         Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
         AppCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands));
         Settings = settings ?? throw new ArgumentNullException(nameof(settings));
+        ExecApprovalsStore = execApprovalsStore ?? throw new ArgumentNullException(nameof(execApprovalsStore));
+        PermissionsRuntimeHost = permissionsRuntimeHost ?? throw new ArgumentNullException(nameof(permissionsRuntimeHost));
     }
 
     public IUiDispatcher Dispatcher { get; }
     public IAppCommands AppCommands { get; }
     public SettingsManager Settings { get; }
+    public IExecApprovalsPresentationStore ExecApprovalsStore { get; }
+    public IPermissionsPageRuntimeHost PermissionsRuntimeHost { get; }
 }
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs
index 1f890f4aa..07db0d61d 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/AppServiceRegistration.cs
@@ -1,3 +1,4 @@
+using OpenClaw.Shared.ExecApprovals;
 using Microsoft.Extensions.DependencyInjection;
 using OpenClawTray.Services;
 
@@ -32,12 +33,12 @@ public static IServiceCollection AddOpenClawTrayCore(this IServiceCollection ser
         services.AddSingleton(context.Dispatcher);
         services.AddSingleton(context.AppCommands);
         services.AddSingleton(context.Settings);
-
-        // Settings facade over the App-owned SettingsManager. Constructed eagerly from the
-        // already-owned singletons so presentation code depends on ISettingsStore, never the
-        // concrete manager. It subscribes to the manager (which App owns) and is not disposed
-        // by the container.
-        services.AddSingleton<ISettingsStore>(new SettingsStore(context.Settings, context.Dispatcher));
+        services.AddSingleton(context.ExecApprovalsStore);
+        services.AddSingleton(context.PermissionsRuntimeHost);
+        // Settings facade over the App-owned SettingsManager. Container-owned so it can dispose
+        // its Saved-event subscription during shutdown.
+        services.AddSingleton<ISettingsStore, SettingsStore>();
+        services.AddSingleton<IPermissionsPageRuntimeSource, PermissionsPageRuntimeSource>();
 
         // Container-owned navigation lifetime manager (disposed with the root provider).
         services.AddSingleton<NavigationScopeManager>();
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs b/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs
index 7bc805150..ba1a1ad80 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs
@@ -1,48 +1,58 @@
+using OpenClawTray.Services;
+
 namespace OpenClawTray.Presentation;
 
 /// <summary>
 /// WinUI-free seam over the App-owned settings for presentation code. It exposes an
-/// immutable <see cref="SettingsSnapshot"/> for reads and a single batched
-/// <see cref="Update"/> for writes, and raises <see cref="Changed"/> only for
-/// <b>external</b> changes (a caller's own <see cref="Update"/> is not echoed back).
+/// immutable <see cref="SettingsSnapshot"/> for reads, supports field-scoped writes against
+/// the live <see cref="SettingsManager"/>, and republishes every persisted save as a typed
+/// <see cref="Changed"/> event tagged with the originating writer token when known.
 /// </summary>
-/// <remarks>
-/// This removes the hand-rolled save/echo bookkeeping that page code-behind used to carry
-/// (the <c>_saving</c>/<c>_loading</c> suppression flags). A view model reads
-/// <see cref="Current"/>, writes through <see cref="Update"/>, and refreshes on
-/// <see cref="Changed"/> without risking a save-storm: the store suppresses the
-/// self-originated notification and marshals external ones onto the UI thread.
-/// The auto-save contract itself is unchanged: <see cref="Update"/> mutates the settings
-/// and saves once, exactly as the previous per-toggle code did; callers still invoke
-/// <c>IAppCommands.NotifySettingsSaved()</c> for the reconnect/re-register side effect.
-/// </remarks>
-public interface ISettingsStore
+public interface ISettingsStore : IDisposable
 {
     /// <summary>An immutable snapshot of the current settings values used by the settings surfaces.</summary>
     SettingsSnapshot Current { get; }
 
-    /// <summary>
-    /// Applies <paramref name="edit"/> to the settings and persists once. The store
-    /// suppresses the <see cref="Changed"/> notification that would otherwise echo back to
-    /// the caller from this save, so a two-way-bound view model cannot loop.
-    /// </summary>
-    void Update(Action<ISettingsEditor> edit);
+    /// <summary>Creates an opaque token that identifies one writer instance.</summary>
+    SettingsWriteOrigin CreateOrigin();
 
     /// <summary>
-    /// Marks the calling thread as performing a store-originated write for the scope's lifetime,
-    /// so a <see cref="SettingsManager.Save"/> raised on that thread is treated as self-originated
-    /// and does not echo <see cref="Changed"/>. Used by App-owned writes that persist settings
-    /// directly (auto-start OS registration, speaker mute) instead of through <see cref="Update"/>,
-    /// so they get the same echo suppression. Dispose on the same thread that created it.
+    /// Applies <paramref name="edit"/> to the live settings manager and persists once. The raised
+    /// <see cref="Changed"/> event carries <paramref name="origin"/> so the matching caller can
+    /// ignore its own notification while other active listeners still refresh.
     /// </summary>
-    IDisposable BeginSelfWrite();
+    void Update(SettingsWriteOrigin? origin, Action<ISettingsEditor> edit);
 
     /// <summary>
-    /// Raised after settings change from a source other than this caller's <see cref="Update"/>
-    /// (for example another surface saving, onboarding, or a background save). Always raised on
-    /// the UI thread.
+    /// Raised after settings are persisted. The event includes the persisted version, a detached
+    /// snapshot, and the originating writer token when the save came through this store.
+    /// External saves published directly through <see cref="SettingsManager.Save"/> use
+    /// <see langword="null"/> origin.
     /// </summary>
-    event EventHandler? Changed;
+    event EventHandler<SettingsChangedEventArgs>? Changed;
+}
+
+/// <summary>Opaque per-writer token used to tag change notifications.</summary>
+public sealed class SettingsWriteOrigin
+{
+    internal SettingsWriteOrigin(long id) => Id = id;
+
+    internal long Id { get; }
+}
+
+/// <summary>Typed settings-change payload published by <see cref="ISettingsStore"/>.</summary>
+public sealed class SettingsChangedEventArgs : EventArgs
+{
+    public SettingsChangedEventArgs(SettingsWriteOrigin? origin, SettingsSnapshot snapshot)
+    {
+        Origin = origin;
+        Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
+        Version = snapshot.Version;
+    }
+
+    public SettingsWriteOrigin? Origin { get; }
+    public long Version { get; }
+    public SettingsSnapshot Snapshot { get; }
 }
 
 /// <summary>
@@ -71,9 +81,20 @@ public interface ISettingsEditor
     bool NotifyStock { set; }
     bool NotifyInfo { set; }
 
+    bool EnableNodeMode { set; }
+    bool EnableMcpServer { set; }
+    bool NodeSystemRunEnabled { set; }
+    bool NodeBrowserProxyEnabled { set; }
+    bool NodeCameraEnabled { set; }
+    bool NodeCanvasEnabled { set; }
+    bool NodeScreenEnabled { set; }
+    bool NodeLocationEnabled { set; }
+    bool NodeTtsEnabled { set; }
+    bool NodeSttEnabled { set; }
+
     bool ScreenRecordingConsentGiven { set; }
     bool CameraRecordingConsentGiven { set; }
-
+    bool VoiceTtsEnabled { set; }
     bool ShowChatToolCalls { set; }
 }
 
@@ -83,6 +104,7 @@ public interface ISettingsEditor
 /// </summary>
 public sealed record SettingsSnapshot
 {
+    public long Version { get; init; }
     public bool AutoStart { get; init; }
     public bool GlobalHotkeyEnabled { get; init; }
     public bool UseLegacyWebChat { get; init; }
@@ -102,6 +124,22 @@ public sealed record SettingsSnapshot
     public bool NotifyStock { get; init; }
     public bool NotifyInfo { get; init; }
 
+    public bool EnableNodeMode { get; init; }
+    public bool EnableMcpServer { get; init; }
+    public bool NodeSystemRunEnabled { get; init; }
+    public bool NodeBrowserProxyEnabled { get; init; }
+    public bool NodeCameraEnabled { get; init; }
+    public bool NodeCanvasEnabled { get; init; }
+    public bool NodeScreenEnabled { get; init; }
+    public bool NodeLocationEnabled { get; init; }
+    public bool NodeTtsEnabled { get; init; }
+    public bool NodeSttEnabled { get; init; }
+    public string SttModelName { get; init; } = "base";
+    public string TtsProvider { get; init; } = "";
+    public string TtsPiperVoiceId { get; init; } = "";
+    public string TtsElevenLabsApiKey { get; init; } = "";
+    public string TtsElevenLabsVoiceId { get; init; } = "";
+
     public bool ScreenRecordingConsentGiven { get; init; }
     public bool CameraRecordingConsentGiven { get; init; }
 
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs
new file mode 100644
index 000000000..a5177f835
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs
@@ -0,0 +1,112 @@
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+using System.Text.Json;
+
+namespace OpenClawTray.Presentation;
+
+internal enum PermissionsCapabilityKey
+{
+    SystemRun,
+    BrowserProxy,
+    Camera,
+    Canvas,
+    Screen,
+    Location,
+    TextToSpeech,
+    SpeechToText,
+}
+
+internal sealed record PermissionsCapabilityState(
+    PermissionsCapabilityKey Key,
+    bool IsOn,
+    bool IsInteractive);
+
+internal enum PermissionsNodeStatusKind
+{
+    Disabled,
+    McpOnly,
+    McpError,
+    Active,
+    Starting,
+    NotConnected,
+}
+
+internal enum PermissionsMcpTokenState
+{
+    None,
+    Pending,
+    Ready,
+}
+
+internal enum PermissionsVoiceSetupRequirement
+{
+    None,
+    SpeechModel,
+    VoiceSetup,
+    SpeechModelAndVoiceSetup,
+}
+
+internal enum PermissionsExecApprovalsStatus
+{
+    None,
+    Saved,
+    SaveFailed,
+    ExternalInvalid,
+}
+
+internal enum PermissionsGatewayAllowlistState
+{
+    NoConfig,
+    ParseFailed,
+    NoCommands,
+    Commands,
+}
+
+internal sealed record PermissionsExecApprovalRule(
+    Guid? Id,
+    string Pattern,
+    double? LastUsedAt,
+    string? LastResolvedPath);
+
+internal sealed record PermissionsRuntimeSourceSnapshot(
+    GatewayConnectionSnapshot ConnectionSnapshot,
+    string? McpStartupError,
+    string McpEndpoint,
+    bool IsMcpTokenReady,
+    int McpServedCapabilityCount,
+    IReadOnlyList<string> LocalNodeCapabilities,
+    IReadOnlyList<string> GatewayAllowCommands,
+    PermissionsGatewayAllowlistState GatewayAllowlistState,
+    PermissionsVoiceSetupRequirement VoiceSetupRequirement);
+
+internal sealed class PermissionsRuntimeSourceChangedEventArgs : EventArgs
+{
+    public PermissionsRuntimeSourceChangedEventArgs(PermissionsRuntimeSourceSnapshot snapshot)
+    {
+        Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
+    }
+
+    public PermissionsRuntimeSourceSnapshot Snapshot { get; }
+}
+
+internal interface IPermissionsPageRuntimeHost
+{
+    event EventHandler? Changed;
+
+    GatewayConnectionSnapshot ConnectionSnapshot { get; }
+    GatewayNodeInfo[] Nodes { get; }
+    string? LocalNodeDeviceId { get; }
+    JsonElement? GatewayConfig { get; }
+    string? McpStartupError { get; }
+    string McpEndpoint { get; }
+    bool IsMcpTokenReady { get; }
+    int McpServedCapabilityCount { get; }
+    PermissionsVoiceSetupRequirement VoiceSetupRequirement { get; }
+}
+
+internal interface IPermissionsPageRuntimeSource
+{
+    event EventHandler<PermissionsRuntimeSourceChangedEventArgs>? Changed;
+
+    PermissionsRuntimeSourceSnapshot Current { get; }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageRuntimeSource.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageRuntimeSource.cs
new file mode 100644
index 000000000..561c3e7a4
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageRuntimeSource.cs
@@ -0,0 +1,124 @@
+using OpenClawTray.Services;
+using System.Text.Json;
+
+namespace OpenClawTray.Presentation;
+
+internal sealed class PermissionsPageRuntimeSource : IPermissionsPageRuntimeSource, IDisposable
+{
+    private readonly IPermissionsPageRuntimeHost _host;
+    private bool _disposed;
+
+    public PermissionsPageRuntimeSource(IPermissionsPageRuntimeHost host)
+    {
+        _host = host ?? throw new ArgumentNullException(nameof(host));
+        _host.Changed += OnHostChanged;
+    }
+
+    public event EventHandler<PermissionsRuntimeSourceChangedEventArgs>? Changed;
+
+    public PermissionsRuntimeSourceSnapshot Current
+    {
+        get
+        {
+            ThrowIfDisposed();
+            return BuildSnapshot();
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        _host.Changed -= OnHostChanged;
+        _disposed = true;
+    }
+
+    private void OnHostChanged(object? sender, EventArgs e)
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        Changed?.Invoke(this, new PermissionsRuntimeSourceChangedEventArgs(BuildSnapshot()));
+    }
+
+    private PermissionsRuntimeSourceSnapshot BuildSnapshot()
+    {
+        var capabilities = NodeCapabilityGating.GetLocalNodeCapabilities(_host.Nodes, _host.LocalNodeDeviceId)
+            ?? Array.Empty<string>();
+        var allowlist = GetGatewayAllowCommands(_host.GatewayConfig);
+
+        return new PermissionsRuntimeSourceSnapshot(
+            _host.ConnectionSnapshot,
+            _host.McpStartupError,
+            _host.McpEndpoint,
+            _host.IsMcpTokenReady,
+            _host.McpServedCapabilityCount,
+            capabilities,
+            allowlist.Commands,
+            allowlist.State,
+            _host.VoiceSetupRequirement);
+    }
+
+    private static GatewayAllowlistProjection GetGatewayAllowCommands(JsonElement? config)
+    {
+        if (!config.HasValue)
+        {
+            return new GatewayAllowlistProjection(
+                PermissionsGatewayAllowlistState.NoConfig,
+                Array.Empty<string>());
+        }
+
+        try
+        {
+            var commands = new List<string>();
+            var root = config.Value;
+            if (root.TryGetProperty("gateway", out var gateway)
+                && gateway.TryGetProperty("nodes", out var nodes)
+                && nodes.TryGetProperty("allowCommands", out var allowCommands)
+                && allowCommands.ValueKind == JsonValueKind.Array)
+            {
+                foreach (var command in allowCommands.EnumerateArray())
+                {
+                    if (command.ValueKind != JsonValueKind.String)
+                    {
+                        return new GatewayAllowlistProjection(
+                            PermissionsGatewayAllowlistState.ParseFailed,
+                            Array.Empty<string>());
+                    }
+
+                    var value = command.GetString();
+                    if (!string.IsNullOrWhiteSpace(value))
+                    {
+                        commands.Add(value);
+                    }
+                }
+            }
+
+            return new GatewayAllowlistProjection(
+                commands.Count == 0
+                    ? PermissionsGatewayAllowlistState.NoCommands
+                    : PermissionsGatewayAllowlistState.Commands,
+                commands);
+        }
+        catch (Exception ex) when (ex is JsonException or InvalidOperationException)
+        {
+            return new GatewayAllowlistProjection(
+                PermissionsGatewayAllowlistState.ParseFailed,
+                Array.Empty<string>());
+        }
+    }
+
+    private sealed record GatewayAllowlistProjection(
+        PermissionsGatewayAllowlistState State,
+        IReadOnlyList<string> Commands);
+
+    private void ThrowIfDisposed()
+    {
+        ObjectDisposedException.ThrowIf(_disposed, this);
+    }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs
index acb50c5e7..836f0827e 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs
@@ -1,32 +1,843 @@
+using OpenClaw.Connection;
+using OpenClaw.Shared.ExecApprovals;
 using OpenClawTray.Services;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
 
 namespace OpenClawTray.Presentation;
 
-/// <summary>
-/// Transient view-model placeholder for the Permissions page. Mirrors
-/// <see cref="SettingsPageViewModel"/>: it exercises DI construction, navigation
-/// activation/deactivation, and scope-driven disposal. It intentionally holds no
-/// behavior yet. Nothing is started in the constructor.
-/// </summary>
-internal sealed class PermissionsPageViewModel : INavigationAware, IDisposable
+internal sealed class PermissionsPageViewModel : INavigationAware, IDisposable, INotifyPropertyChanged
 {
-    public PermissionsPageViewModel(IAppCommands appCommands, SettingsManager settings, IUiDispatcher dispatcher)
+    private readonly ISettingsStore _settingsStore;
+    private readonly IExecApprovalsPresentationStore _execApprovalsStore;
+    private readonly IAppCommands _appCommands;
+    private readonly IUiDispatcher _dispatcher;
+    private readonly IPermissionsPageRuntimeSource _runtimeSource;
+    private readonly SettingsWriteOrigin _settingsOrigin;
+    private readonly ExecApprovalsWriterOrigin _execApprovalsOrigin;
+    private readonly SemaphoreSlim _execApprovalsMutationLock = new(1, 1);
+
+    private bool _subscribed;
+    private bool _loadingSettings;
+    private bool _disposed;
+    private string? _execApprovalsBaseHash;
+    private long _execApprovalsRefreshGeneration;
+    private long _lastExecApprovalsChangeSequence;
+    private long _lastAppliedSettingsVersion = -1;
+
+    private bool _nodeModeEnabled;
+    private bool _mcpEnabled;
+    private bool _nodeSystemRunEnabled;
+    private bool _nodeBrowserProxyEnabled;
+    private bool _nodeCameraEnabled;
+    private bool _nodeCanvasEnabled;
+    private bool _nodeScreenEnabled;
+    private bool _nodeLocationEnabled;
+    private bool _nodeTtsEnabled;
+    private bool _nodeSttEnabled;
+    private IReadOnlyList<PermissionsCapabilityState> _capabilities = Array.Empty<PermissionsCapabilityState>();
+    private bool _areFeaturesEnabled;
+    private string _featuresDescriptionResourceKey = "PermissionsPage_FeaturesDescription_Disabled";
+    private PermissionsNodeStatusKind _nodeStatusKind = PermissionsNodeStatusKind.Disabled;
+    private string _nodeStatusResourceKey = "PermissionsPage_NodeStatus_Disabled";
+    private string? _nodeDetailsResourceKey = "PermissionsPage_NodeStatus_DisabledDetails";
+    private string? _nodeDetailsErrorText;
+    private int _mcpServedCapabilityCount;
+    private IReadOnlyList<string> _localNodeCapabilities = Array.Empty<string>();
+    private PermissionsMcpTokenState _mcpTokenState;
+    private string _mcpEndpoint = "http://127.0.0.1:8765/mcp";
+    private string _mcpStatusResourceKey = "PermissionsPage_McpStatus_TokenPending";
+    private string? _mcpStatusErrorText;
+    private bool _voiceSettingsVisible;
+    private PermissionsVoiceSetupRequirement _voiceSetupRequirement;
+    private string? _voiceSetupHelpResourceKey;
+    private IReadOnlyList<string> _allowCommands = Array.Empty<string>();
+    private PermissionsGatewayAllowlistState _gatewayAllowlistState;
+    private string _defaultExecActionTag = "deny";
+    private IReadOnlyList<PermissionsExecApprovalRule> _execApprovalRules = Array.Empty<PermissionsExecApprovalRule>();
+    private ExecApprovalsSnapshotFailure? _execApprovalsFailure;
+    private PermissionsExecApprovalsStatus _execApprovalsStatus;
+    private long _execApprovalsStatusVersion;
+
+    public PermissionsPageViewModel(
+        ISettingsStore settingsStore,
+        IExecApprovalsPresentationStore execApprovalsStore,
+        IAppCommands appCommands,
+        IUiDispatcher dispatcher,
+        IPermissionsPageRuntimeSource runtimeSource)
     {
-        AppCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands));
-        Settings = settings ?? throw new ArgumentNullException(nameof(settings));
-        Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+        _settingsStore = settingsStore ?? throw new ArgumentNullException(nameof(settingsStore));
+        _execApprovalsStore = execApprovalsStore ?? throw new ArgumentNullException(nameof(execApprovalsStore));
+        _appCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands));
+        _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+        _runtimeSource = runtimeSource ?? throw new ArgumentNullException(nameof(runtimeSource));
+        _settingsOrigin = _settingsStore.CreateOrigin();
+        _execApprovalsOrigin = _execApprovalsStore.CreateWriterOrigin();
+        RecomputePresentation(_runtimeSource.Current);
     }
 
-    internal IAppCommands AppCommands { get; }
-    internal SettingsManager Settings { get; }
-    internal IUiDispatcher Dispatcher { get; }
+    public event PropertyChangedEventHandler? PropertyChanged;
+    public event EventHandler? ExternalChanged;
 
+    internal ISettingsStore SettingsStore => _settingsStore;
+    internal IExecApprovalsPresentationStore ExecApprovalsStore => _execApprovalsStore;
+    internal IAppCommands AppCommands => _appCommands;
+    internal IUiDispatcher Dispatcher => _dispatcher;
+    internal IPermissionsPageRuntimeSource RuntimeSource => _runtimeSource;
     internal bool IsActive { get; private set; }
-    internal bool IsDisposed { get; private set; }
+    internal bool IsDisposed => _disposed;
+
+    public bool NodeModeEnabled
+    {
+        get => _nodeModeEnabled;
+        set
+        {
+            if (SetField(ref _nodeModeEnabled, value) && !_loadingSettings)
+            {
+                PersistSetting(edit => edit.EnableNodeMode = value);
+            }
+        }
+    }
+
+    public bool McpEnabled
+    {
+        get => _mcpEnabled;
+        set
+        {
+            if (SetField(ref _mcpEnabled, value) && !_loadingSettings)
+            {
+                PersistSetting(edit => edit.EnableMcpServer = value);
+            }
+        }
+    }
+
+    public IReadOnlyList<PermissionsCapabilityState> Capabilities => _capabilities;
+    public bool AreFeaturesEnabled => _areFeaturesEnabled;
+    public string FeaturesDescriptionResourceKey => _featuresDescriptionResourceKey;
+    public PermissionsNodeStatusKind NodeStatusKind => _nodeStatusKind;
+    public string NodeStatusResourceKey => _nodeStatusResourceKey;
+    public string? NodeDetailsResourceKey => _nodeDetailsResourceKey;
+    public string? NodeDetailsErrorText => _nodeDetailsErrorText;
+    public int McpServedCapabilityCount => _mcpServedCapabilityCount;
+    public IReadOnlyList<string> LocalNodeCapabilities => _localNodeCapabilities;
+    public int LocalNodeCapabilityCount => _localNodeCapabilities.Count;
+    public PermissionsMcpTokenState McpTokenState => _mcpTokenState;
+    public string McpEndpoint => _mcpEndpoint;
+    public string McpStatusResourceKey => _mcpStatusResourceKey;
+    public string? McpStatusErrorText => _mcpStatusErrorText;
+    public bool VoiceSettingsVisible => _voiceSettingsVisible;
+    public PermissionsVoiceSetupRequirement VoiceSetupRequirement => _voiceSetupRequirement;
+    public string? VoiceSetupHelpResourceKey => _voiceSetupHelpResourceKey;
+    public IReadOnlyList<string> AllowCommands => _allowCommands;
+    public PermissionsGatewayAllowlistState GatewayAllowlistState => _gatewayAllowlistState;
+
+    public string DefaultExecActionTag => _defaultExecActionTag;
+
+    public IReadOnlyList<PermissionsExecApprovalRule> ExecApprovalRules => _execApprovalRules;
+    public ExecApprovalsSnapshotFailure? ExecApprovalsFailure => _execApprovalsFailure;
+    public PermissionsExecApprovalsStatus ExecApprovalsStatus => _execApprovalsStatus;
+    public long ExecApprovalsStatusVersion => _execApprovalsStatusVersion;
+
+    public void Activate(object? parameter)
+    {
+        ThrowIfDisposed();
+        IsActive = true;
+        if (!_subscribed)
+        {
+            _settingsStore.Changed += OnSettingsChanged;
+            _execApprovalsStore.Changed += OnExecApprovalsChanged;
+            _runtimeSource.Changed += OnRuntimeChanged;
+            _subscribed = true;
+        }
+
+        LoadCurrentSettings();
+        var refreshGeneration = Interlocked.Increment(ref _execApprovalsRefreshGeneration);
+        _ = RefreshExecApprovalsAsync(refreshGeneration);
+        RecomputePresentation(_runtimeSource.Current);
+    }
+
+    public void Deactivate()
+    {
+        Interlocked.Increment(ref _execApprovalsRefreshGeneration);
+        if (_subscribed)
+        {
+            _settingsStore.Changed -= OnSettingsChanged;
+            _execApprovalsStore.Changed -= OnExecApprovalsChanged;
+            _runtimeSource.Changed -= OnRuntimeChanged;
+            _subscribed = false;
+        }
+
+        IsActive = false;
+    }
+
+    public void Dispose()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        Deactivate();
+        _disposed = true;
+    }
+
+    public void SetCapabilityEnabled(PermissionsCapabilityKey key, bool value)
+    {
+        switch (key)
+        {
+            case PermissionsCapabilityKey.SystemRun:
+                PersistCapabilitySetting(ref _nodeSystemRunEnabled, value, edit => edit.NodeSystemRunEnabled = value);
+                break;
+            case PermissionsCapabilityKey.BrowserProxy:
+                PersistCapabilitySetting(ref _nodeBrowserProxyEnabled, value, edit => edit.NodeBrowserProxyEnabled = value);
+                break;
+            case PermissionsCapabilityKey.Camera:
+                PersistCapabilitySetting(ref _nodeCameraEnabled, value, edit => edit.NodeCameraEnabled = value);
+                break;
+            case PermissionsCapabilityKey.Canvas:
+                PersistCapabilitySetting(ref _nodeCanvasEnabled, value, edit => edit.NodeCanvasEnabled = value);
+                break;
+            case PermissionsCapabilityKey.Screen:
+                PersistCapabilitySetting(ref _nodeScreenEnabled, value, edit => edit.NodeScreenEnabled = value);
+                break;
+            case PermissionsCapabilityKey.Location:
+                PersistCapabilitySetting(ref _nodeLocationEnabled, value, edit => edit.NodeLocationEnabled = value);
+                break;
+            case PermissionsCapabilityKey.TextToSpeech:
+                PersistCapabilitySetting(ref _nodeTtsEnabled, value, edit => edit.NodeTtsEnabled = value);
+                break;
+            case PermissionsCapabilityKey.SpeechToText:
+                PersistCapabilitySetting(ref _nodeSttEnabled, value, edit => edit.NodeSttEnabled = value);
+                break;
+        }
+    }
+
+    public Task<bool> SetDefaultExecActionAsync(string? action)
+    {
+        var normalized = NormalizeAction(action);
+        return string.Equals(_defaultExecActionTag, normalized, StringComparison.Ordinal)
+            ? Task.FromResult(true)
+            : SaveExecApprovalsAsync(new ExecApprovalsMutation(ExecApprovalsMutationKind.DefaultAction, normalized, null));
+    }
+
+    public Task<bool> TryAddExecApprovalRuleAsync(string pattern)
+    {
+        var trimmed = pattern?.Trim();
+        if (!OpenClaw.Shared.ExecApprovals.ExecApprovalsStore.IsValidAllowlistPattern(trimmed))
+        {
+            return Task.FromResult(false);
+        }
+
+        return SaveExecApprovalsAsync(new ExecApprovalsMutation(ExecApprovalsMutationKind.AddRule, null, trimmed));
+    }
+
+    public Task<bool> RemoveExecApprovalRuleAsync(PermissionsExecApprovalRule rule)
+    {
+        ArgumentNullException.ThrowIfNull(rule);
+        return SaveExecApprovalsAsync(new ExecApprovalsMutation(
+            ExecApprovalsMutationKind.RemoveRule,
+            null,
+            rule.Pattern,
+            rule.Id));
+    }
+
+    private void PersistSetting(Action<ISettingsEditor> edit)
+    {
+        _settingsStore.Update(_settingsOrigin, edit);
+        _appCommands.NotifySettingsSaved();
+        RecomputePresentation(_runtimeSource.Current);
+    }
+
+    private void PersistCapabilitySetting(
+        ref bool field,
+        bool value,
+        Action<ISettingsEditor> edit)
+    {
+        if (field == value)
+        {
+            return;
+        }
+
+        field = value;
+        if (!_loadingSettings)
+        {
+            PersistSetting(edit);
+        }
+    }
+
+    private void OnSettingsChanged(object? sender, SettingsChangedEventArgs e)
+    {
+        if (!IsActive || !TryAdvanceSettingsVersion(e.Version))
+        {
+            return;
+        }
+
+        var isOwnChange = ReferenceEquals(e.Origin, _settingsOrigin);
+        HandleOnUiThread(() =>
+        {
+            if (!IsActive)
+            {
+                return;
+            }
+
+            if (e.Version != Volatile.Read(ref _lastAppliedSettingsVersion))
+            {
+                return;
+            }
+
+            LoadSettings(e.Snapshot);
+            if (e.Version != Volatile.Read(ref _lastAppliedSettingsVersion))
+            {
+                return;
+            }
+
+            if (isOwnChange)
+            {
+                return;
+            }
+
+            RecomputePresentation(_runtimeSource.Current);
+            ExternalChanged?.Invoke(this, EventArgs.Empty);
+        });
+    }
+
+    private void OnRuntimeChanged(object? sender, PermissionsRuntimeSourceChangedEventArgs e)
+    {
+        HandleOnUiThread(() =>
+        {
+            if (!IsActive)
+            {
+                return;
+            }
+
+            RecomputePresentation(e.Snapshot);
+        });
+    }
+
+    private void OnExecApprovalsChanged(object? sender, ExecApprovalsChangedEventArgs e)
+    {
+        if (!TryAcceptExecApprovalsChangeSequence(e.Sequence))
+        {
+            return;
+        }
+
+        if (ReferenceEquals(e.Origin, _execApprovalsOrigin))
+        {
+            return;
+        }
+
+        Interlocked.Increment(ref _execApprovalsRefreshGeneration);
+        HandleOnUiThread(() =>
+        {
+            if (!IsActive
+                || e.Sequence != Volatile.Read(ref _lastExecApprovalsChangeSequence))
+            {
+                return;
+            }
+
+            if (e.Snapshot is not null)
+            {
+                ApplyExecSnapshot(e.Snapshot);
+                SetExecApprovalsFailure(null, PermissionsExecApprovalsStatus.None, bumpVersion: false);
+                return;
+            }
+
+            if (e.LastValidSnapshot is not null)
+            {
+                ApplyExecSnapshot(e.LastValidSnapshot);
+            }
+
+            SetExecApprovalsFailure(e.Failure, PermissionsExecApprovalsStatus.ExternalInvalid, bumpVersion: true);
+        });
+    }
+
+    private bool TryAcceptExecApprovalsChangeSequence(long sequence)
+    {
+        while (true)
+        {
+            var current = Volatile.Read(ref _lastExecApprovalsChangeSequence);
+            if (sequence <= current)
+            {
+                return false;
+            }
+
+            if (Interlocked.CompareExchange(ref _lastExecApprovalsChangeSequence, sequence, current) == current)
+            {
+                return true;
+            }
+        }
+    }
+
+    private void LoadCurrentSettings()
+    {
+        var snapshot = _settingsStore.Current;
+        TryAdvanceSettingsVersion(snapshot.Version);
+        if (snapshot.Version == Volatile.Read(ref _lastAppliedSettingsVersion))
+        {
+            LoadSettings(snapshot);
+        }
+    }
+
+    private bool TryAdvanceSettingsVersion(long version)
+    {
+        while (true)
+        {
+            var current = Volatile.Read(ref _lastAppliedSettingsVersion);
+            if (version <= current)
+            {
+                return false;
+            }
+
+            if (Interlocked.CompareExchange(ref _lastAppliedSettingsVersion, version, current) == current)
+            {
+                return true;
+            }
+        }
+    }
 
-    public void Activate(object? parameter) => IsActive = true;
+    private void LoadSettings(SettingsSnapshot snapshot)
+    {
+        _loadingSettings = true;
+        try
+        {
+            SetField(ref _nodeModeEnabled, snapshot.EnableNodeMode, nameof(NodeModeEnabled));
+            SetField(ref _mcpEnabled, snapshot.EnableMcpServer, nameof(McpEnabled));
+            _nodeSystemRunEnabled = snapshot.NodeSystemRunEnabled;
+            _nodeBrowserProxyEnabled = snapshot.NodeBrowserProxyEnabled;
+            _nodeCameraEnabled = snapshot.NodeCameraEnabled;
+            _nodeCanvasEnabled = snapshot.NodeCanvasEnabled;
+            _nodeScreenEnabled = snapshot.NodeScreenEnabled;
+            _nodeLocationEnabled = snapshot.NodeLocationEnabled;
+            _nodeTtsEnabled = snapshot.NodeTtsEnabled;
+            _nodeSttEnabled = snapshot.NodeSttEnabled;
+        }
+        finally
+        {
+            _loadingSettings = false;
+        }
+    }
+
+    private void RecomputePresentation(PermissionsRuntimeSourceSnapshot runtime)
+    {
+        var featuresEnabled = _nodeModeEnabled || _mcpEnabled;
+        SetField(ref _areFeaturesEnabled, featuresEnabled, nameof(AreFeaturesEnabled));
+        SetField(
+            ref _featuresDescriptionResourceKey,
+            featuresEnabled ? "PermissionsPage_FeaturesDescription_Enabled" : "PermissionsPage_FeaturesDescription_Disabled",
+            nameof(FeaturesDescriptionResourceKey));
+        SetField(ref _capabilities, BuildCapabilities(featuresEnabled), nameof(Capabilities));
+        SetField(ref _allowCommands, runtime.GatewayAllowCommands, nameof(AllowCommands));
+        SetField(ref _gatewayAllowlistState, runtime.GatewayAllowlistState, nameof(GatewayAllowlistState));
+        SetField(ref _mcpEndpoint, runtime.McpEndpoint, nameof(McpEndpoint));
+        SetField(ref _mcpServedCapabilityCount, runtime.McpServedCapabilityCount, nameof(McpServedCapabilityCount));
+        SetField(ref _localNodeCapabilities, runtime.LocalNodeCapabilities, nameof(LocalNodeCapabilities));
+        OnPropertyChanged(nameof(LocalNodeCapabilityCount));
+
+        var mcpTokenState = !_mcpEnabled
+            ? PermissionsMcpTokenState.None
+            : runtime.IsMcpTokenReady
+                ? PermissionsMcpTokenState.Ready
+                : PermissionsMcpTokenState.Pending;
+        SetField(ref _mcpTokenState, mcpTokenState, nameof(McpTokenState));
+        SetField(
+            ref _mcpStatusResourceKey,
+            runtime.IsMcpTokenReady ? "PermissionsPage_McpStatus_TokenReady" : "PermissionsPage_McpStatus_TokenPending",
+            nameof(McpStatusResourceKey));
+        SetField(ref _mcpStatusErrorText, runtime.McpStartupError, nameof(McpStatusErrorText));
+
+        var voiceVisible = _nodeSttEnabled || _nodeTtsEnabled;
+        SetField(ref _voiceSettingsVisible, voiceVisible, nameof(VoiceSettingsVisible));
+        var voiceRequirement = runtime.VoiceSetupRequirement;
+        SetField(ref _voiceSetupRequirement, voiceRequirement, nameof(VoiceSetupRequirement));
+        SetField(ref _voiceSetupHelpResourceKey, ToVoiceHelpResourceKey(voiceRequirement), nameof(VoiceSetupHelpResourceKey));
+
+        ApplyNodeStatus(runtime);
+    }
+
+    private IReadOnlyList<PermissionsCapabilityState> BuildCapabilities(bool featuresEnabled) =>
+        new[]
+        {
+            new PermissionsCapabilityState(PermissionsCapabilityKey.SystemRun, _nodeSystemRunEnabled, featuresEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.BrowserProxy, _nodeBrowserProxyEnabled, featuresEnabled && _nodeModeEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.Camera, _nodeCameraEnabled, featuresEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.Canvas, _nodeCanvasEnabled, featuresEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.Screen, _nodeScreenEnabled, featuresEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.Location, _nodeLocationEnabled, featuresEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.TextToSpeech, _nodeTtsEnabled, featuresEnabled),
+            new PermissionsCapabilityState(PermissionsCapabilityKey.SpeechToText, _nodeSttEnabled, featuresEnabled),
+        };
+
+    private void ApplyNodeStatus(PermissionsRuntimeSourceSnapshot runtime)
+    {
+        var snapshot = runtime.ConnectionSnapshot;
+        var nodeState = snapshot.NodeState;
+        var operatorConnected = snapshot.OperatorState == RoleConnectionState.Connected;
+        var mcpError = runtime.McpStartupError;
+
+        if (!_nodeModeEnabled)
+        {
+            if (_mcpEnabled)
+            {
+                if (!string.IsNullOrWhiteSpace(mcpError))
+                {
+                    SetNodeStatus(PermissionsNodeStatusKind.McpError, "PermissionsPage_NodeStatus_McpError", detailsKey: null, errorText: mcpError);
+                }
+                else
+                {
+                    SetNodeStatus(PermissionsNodeStatusKind.McpOnly, "PermissionsPage_NodeStatus_McpOnly", "PermissionsPage_NodeStatus_McpOnlyDetailsFormat", errorText: null);
+                }
+            }
+            else
+            {
+                SetNodeStatus(PermissionsNodeStatusKind.Disabled, "PermissionsPage_NodeStatus_Disabled", "PermissionsPage_NodeStatus_DisabledDetails", errorText: null);
+            }
+
+            return;
+        }
+
+        if (_mcpEnabled && !string.IsNullOrWhiteSpace(mcpError))
+        {
+            SetNodeStatus(PermissionsNodeStatusKind.McpError, "PermissionsPage_NodeStatus_McpError", detailsKey: null, errorText: mcpError);
+        }
+        else if (nodeState == RoleConnectionState.Connected && operatorConnected)
+        {
+            SetNodeStatus(
+                PermissionsNodeStatusKind.Active,
+                "PermissionsPage_NodeStatus_Active",
+                runtime.LocalNodeCapabilities.Count > 0
+                    ? "PermissionsPage_NodeStatus_ActiveDetailsFormat"
+                    : "PermissionsPage_NodeStatus_NoCapabilities",
+                errorText: null);
+        }
+        else if (nodeState == RoleConnectionState.Connecting)
+        {
+            SetNodeStatus(PermissionsNodeStatusKind.Starting, "PermissionsPage_NodeStatus_Starting", "PermissionsPage_NodeStatus_NotConnectedDetails", errorText: null);
+        }
+        else
+        {
+            SetNodeStatus(
+                PermissionsNodeStatusKind.NotConnected,
+                "PermissionsPage_NodeStatus_NotConnected",
+                _mcpEnabled && string.IsNullOrWhiteSpace(mcpError)
+                    ? "PermissionsPage_NodeStatus_McpOnlyDetailsFormat"
+                    : "PermissionsPage_NodeStatus_NotConnectedDetails",
+                errorText: null);
+        }
+    }
+
+    private void SetNodeStatus(PermissionsNodeStatusKind kind, string statusKey, string? detailsKey, string? errorText)
+    {
+        SetField(ref _nodeStatusKind, kind, nameof(NodeStatusKind));
+        SetField(ref _nodeStatusResourceKey, statusKey, nameof(NodeStatusResourceKey));
+        SetField(ref _nodeDetailsResourceKey, detailsKey, nameof(NodeDetailsResourceKey));
+        SetField(ref _nodeDetailsErrorText, errorText, nameof(NodeDetailsErrorText));
+    }
+
+    private async Task RefreshExecApprovalsAsync(long refreshGeneration)
+    {
+        var result = await _execApprovalsStore.GetSnapshotReadOnlyAsync();
+        HandleOnUiThread(() =>
+        {
+            if (IsActive
+                && refreshGeneration == Volatile.Read(ref _execApprovalsRefreshGeneration))
+            {
+                LoadExecApprovals(result);
+            }
+        });
+    }
+
+    private async Task<bool> SaveExecApprovalsAsync(ExecApprovalsMutation mutation)
+    {
+        await _execApprovalsMutationLock.WaitAsync();
+        try
+        {
+            var mutationGeneration = Interlocked.Increment(ref _execApprovalsRefreshGeneration);
+            try
+            {
+                for (var attempt = 0; attempt < 3; attempt++)
+                {
+                    var snapshotResult = await _execApprovalsStore.GetSnapshotReadOnlyAsync();
+                    var currentSnapshot = snapshotResult.Snapshot ?? snapshotResult.LastValidSnapshot;
+                    var baseHash = attempt == 0 && !string.IsNullOrWhiteSpace(_execApprovalsBaseHash)
+                        ? _execApprovalsBaseHash!
+                        : currentSnapshot?.Hash ?? snapshotResult.Failure?.Hash;
+                    if (string.IsNullOrWhiteSpace(baseHash))
+                    {
+                        break;
+                    }
+
+                    var workingFile = CloneFile(currentSnapshot?.File ?? new ExecApprovalsFile());
+                    ApplyMutation(workingFile, mutation);
+                    var updated = await _execApprovalsStore.ReplaceAsync(baseHash, workingFile, _execApprovalsOrigin);
+                    if (updated is null)
+                    {
+                        _execApprovalsBaseHash = currentSnapshot?.Hash ?? baseHash;
+                        continue;
+                    }
+
+                    HandleOnUiThread(() => CompleteExecApprovalsMutation(updated, mutationGeneration));
+                    return true;
+                }
+            }
+            catch (IOException)
+            {
+            }
+            catch (UnauthorizedAccessException)
+            {
+            }
+
+            SetExecApprovalsFailure(_execApprovalsFailure, PermissionsExecApprovalsStatus.SaveFailed, bumpVersion: true);
+            return false;
+        }
+        finally
+        {
+            _execApprovalsMutationLock.Release();
+        }
+    }
+
+    private void CompleteExecApprovalsMutation(ExecApprovalsSnapshot updated, long mutationGeneration)
+    {
+        if (!IsActive)
+        {
+            return;
+        }
+
+        if (mutationGeneration != Volatile.Read(ref _execApprovalsRefreshGeneration))
+        {
+            var refreshGeneration = Interlocked.Increment(ref _execApprovalsRefreshGeneration);
+            _ = RefreshExecApprovalsAsync(refreshGeneration);
+            return;
+        }
+
+        ApplyExecSnapshot(updated);
+        SetExecApprovalsFailure(null, PermissionsExecApprovalsStatus.Saved, bumpVersion: true);
+    }
+
+    private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutation)
+    {
+        file.Version = 1;
+        file.Defaults ??= new ExecApprovalsDefaults();
+        file.Agents ??= new Dictionary<string, ExecApprovalsAgent>(StringComparer.Ordinal);
+        if (!file.Agents.TryGetValue("main", out var main) || main is null)
+        {
+            main = new ExecApprovalsAgent();
+            file.Agents["main"] = main;
+        }
+
+        if (mutation.Kind == ExecApprovalsMutationKind.DefaultAction)
+        {
+            var (security, ask) = mutation.Action switch
+            {
+                "allow" => (ExecSecurity.Full, ExecAsk.Off),
+                "prompt" => (ExecSecurity.Allowlist, ExecAsk.OnMiss),
+                _ => (ExecSecurity.Allowlist, ExecAsk.Off),
+            };
+
+            file.Defaults.Security = security;
+            file.Defaults.Ask = ask;
+            file.Defaults.AskFallback = ExecSecurity.Deny;
+            file.Defaults.AutoAllowSkills ??= false;
+            main.Security = security;
+            main.Ask = ask;
+            main.AskFallback = ExecSecurity.Deny;
+            return;
+        }
+
+        var allowlist = main.Allowlist ??= new List<ExecAllowlistEntry>();
+        if (mutation.Kind == ExecApprovalsMutationKind.AddRule)
+        {
+            if ((main.Security ?? file.Defaults.Security ?? ExecSecurity.Deny) == ExecSecurity.Deny)
+            {
+                main.Security = ExecSecurity.Allowlist;
+                main.Ask = ExecAsk.Off;
+                main.AskFallback = ExecSecurity.Deny;
+            }
+
+            if (!allowlist.Any(entry => string.Equals(entry.Pattern?.Trim(), mutation.Pattern, StringComparison.OrdinalIgnoreCase)))
+            {
+                allowlist.Add(new ExecAllowlistEntry
+                {
+                    Id = Guid.NewGuid(),
+                    Pattern = mutation.Pattern,
+                });
+            }
 
-    public void Deactivate() => IsActive = false;
+            return;
+        }
+
+        allowlist.RemoveAll(entry =>
+            mutation.RuleId.HasValue
+                ? entry.Id == mutation.RuleId
+                : entry.Id is null
+                    && string.Equals(entry.Pattern?.Trim(), mutation.Pattern, StringComparison.OrdinalIgnoreCase));
+    }
+
+    private void LoadExecApprovals(ExecApprovalsReadOnlySnapshotResult result)
+    {
+        if (result.Snapshot is not null)
+        {
+            ApplyExecSnapshot(result.Snapshot);
+            SetExecApprovalsFailure(null, PermissionsExecApprovalsStatus.None, bumpVersion: false);
+        }
+        else if (result.LastValidSnapshot is not null)
+        {
+            ApplyExecSnapshot(result.LastValidSnapshot);
+            SetExecApprovalsFailure(result.Failure, PermissionsExecApprovalsStatus.ExternalInvalid, bumpVersion: false);
+        }
+        else
+        {
+            _execApprovalsBaseHash = result.Failure?.Hash;
+            SetField(ref _defaultExecActionTag, "deny", nameof(DefaultExecActionTag));
+            SetField(ref _execApprovalRules, Array.Empty<PermissionsExecApprovalRule>(), nameof(ExecApprovalRules));
+            SetExecApprovalsFailure(result.Failure, PermissionsExecApprovalsStatus.None, bumpVersion: false);
+        }
+    }
+
+    private void ApplyExecSnapshot(ExecApprovalsSnapshot snapshot)
+    {
+        _execApprovalsBaseHash = snapshot.Hash;
+        SetField(ref _defaultExecActionTag, MapDefaultAction(snapshot.File), nameof(DefaultExecActionTag));
+        var rules = ((IEnumerable<ExecAllowlistEntry>)(snapshot.File.Agents is not null
+                && snapshot.File.Agents.TryGetValue("main", out var main)
+                && main?.Allowlist is not null
+                ? main.Allowlist
+                : Array.Empty<ExecAllowlistEntry>()))
+            .Where(entry => !string.IsNullOrWhiteSpace(entry.Pattern))
+            .Select(entry => new PermissionsExecApprovalRule(entry.Id, entry.Pattern!, entry.LastUsedAt, entry.LastResolvedPath))
+            .ToArray();
+        SetField(ref _execApprovalRules, rules, nameof(ExecApprovalRules));
+    }
+
+    private void SetExecApprovalsFailure(
+        ExecApprovalsSnapshotFailure? failure,
+        PermissionsExecApprovalsStatus status,
+        bool bumpVersion)
+    {
+        SetField(ref _execApprovalsFailure, failure, nameof(ExecApprovalsFailure));
+        SetField(ref _execApprovalsStatus, status, nameof(ExecApprovalsStatus));
+        if (bumpVersion)
+        {
+            _execApprovalsStatusVersion++;
+            OnPropertyChanged(nameof(ExecApprovalsStatusVersion));
+        }
+    }
+
+    private void HandleOnUiThread(Action action)
+    {
+        if (_dispatcher.HasThreadAccess)
+        {
+            action();
+            return;
+        }
+
+        _dispatcher.TryEnqueue(action);
+    }
+
+    private static string? ToVoiceHelpResourceKey(PermissionsVoiceSetupRequirement requirement) => requirement switch
+    {
+        PermissionsVoiceSetupRequirement.SpeechModel => "PermissionsPage_VoiceSettingsHelp_SpeechModel",
+        PermissionsVoiceSetupRequirement.VoiceSetup => "PermissionsPage_VoiceSettingsHelp_VoiceSetup",
+        PermissionsVoiceSetupRequirement.SpeechModelAndVoiceSetup => "PermissionsPage_VoiceSettingsHelp_Both",
+        _ => null,
+    };
+
+    private static string MapDefaultAction(ExecApprovalsFile file)
+    {
+        file.Defaults ??= new ExecApprovalsDefaults();
+        file.Agents ??= new Dictionary<string, ExecApprovalsAgent>(StringComparer.Ordinal);
+        file.Agents.TryGetValue("main", out var main);
+        var security = main?.Security ?? file.Defaults.Security ?? ExecSecurity.Deny;
+        var ask = main?.Ask ?? file.Defaults.Ask ?? ExecAsk.OnMiss;
+        return security switch
+        {
+            ExecSecurity.Full => "allow",
+            ExecSecurity.Allowlist when ask is ExecAsk.OnMiss or ExecAsk.Always => "prompt",
+            _ => "deny",
+        };
+    }
+
+    private static string NormalizeAction(string? action)
+    {
+        if (string.Equals(action, "allow", StringComparison.OrdinalIgnoreCase))
+        {
+            return "allow";
+        }
+
+        if (string.Equals(action, "prompt", StringComparison.OrdinalIgnoreCase)
+            || string.Equals(action, "ask", StringComparison.OrdinalIgnoreCase))
+        {
+            return "prompt";
+        }
+
+        return "deny";
+    }
+
+    private static ExecApprovalsFile CloneFile(ExecApprovalsFile file) =>
+        new()
+        {
+            Version = file.Version,
+            Socket = file.Socket is null ? null : new ExecApprovalsSocketConfig
+            {
+                Path = file.Socket.Path,
+                Token = file.Socket.Token,
+            },
+            Defaults = file.Defaults is null ? null : new ExecApprovalsDefaults
+            {
+                Security = file.Defaults.Security,
+                Ask = file.Defaults.Ask,
+                AskFallback = file.Defaults.AskFallback,
+                AutoAllowSkills = file.Defaults.AutoAllowSkills,
+            },
+            Agents = file.Agents?.ToDictionary(
+                pair => pair.Key,
+                pair => new ExecApprovalsAgent
+                {
+                    Security = pair.Value.Security,
+                    Ask = pair.Value.Ask,
+                    AskFallback = pair.Value.AskFallback,
+                    AutoAllowSkills = pair.Value.AutoAllowSkills,
+                    Allowlist = pair.Value.Allowlist?.Select(entry => new ExecAllowlistEntry
+                    {
+                        Id = entry.Id,
+                        Pattern = entry.Pattern,
+                        LastUsedAt = entry.LastUsedAt,
+                        LastResolvedPath = entry.LastResolvedPath,
+                    }).ToList(),
+                },
+                StringComparer.Ordinal),
+        };
+
+    private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
+    {
+        if (EqualityComparer<T>.Default.Equals(field, value))
+        {
+            return false;
+        }
+
+        field = value;
+        OnPropertyChanged(propertyName);
+        return true;
+    }
+
+    private void OnPropertyChanged(string? propertyName) =>
+        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+    private void ThrowIfDisposed()
+    {
+        ObjectDisposedException.ThrowIf(_disposed, this);
+    }
+
+    private enum ExecApprovalsMutationKind
+    {
+        DefaultAction,
+        AddRule,
+        RemoveRule,
+    }
 
-    public void Dispose() => IsDisposed = true;
+    private sealed record ExecApprovalsMutation(
+        ExecApprovalsMutationKind Kind,
+        string? Action,
+        string? Pattern,
+        Guid? RuleId = null);
 }
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs
index b430c976e..835ee159c 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs
@@ -10,16 +10,17 @@ namespace OpenClawTray.Presentation;
 /// logic that previously lived in the page code-behind: it loads the current values from
 /// <see cref="ISettingsStore"/> on activation, exposes two-way-bindable properties, and persists
 /// each change through the store while preserving the exact auto-save contract
-/// (mutate -> save -> notify) and the two chat side effects.
+/// (mutate -> save -> notify).
 /// </summary>
 /// <remarks>
 /// Echo handling lives in the store, not here: a property change persists through
-/// <see cref="ISettingsStore.Update"/> (which suppresses the self-originated notification), and an
-/// external change arrives via <see cref="ISettingsStore.Changed"/> and reloads the properties
-/// under the <c>_loading</c> guard so it cannot re-persist. View-only side effects (the "Saved"
-/// indicator flash and refreshing the view-owned gateway section) are surfaced as events the page
-/// handles; OS- and WinUI-coupled work (auto-start registration, speaker mute, chat tool-call
-/// visibility) is delegated to <see cref="IAppCommands"/>, so this type stays free of WinUI and OS APIs.
+/// <see cref="ISettingsStore.Update"/>, and the resulting <see cref="ISettingsStore.Changed"/>
+/// event is ignored only when it carries this instance's writer token. Any other change arrives
+/// via <see cref="ISettingsStore.Changed"/> and reloads the properties under the <c>_loading</c>
+/// guard so it cannot re-persist. View-only side effects (the "Saved" indicator flash and
+/// refreshing the view-owned gateway section) are surfaced as events the page handles. OS-coupled
+/// auto-start registration is delegated to <see cref="IAppCommands"/>, so this type stays free of
+/// WinUI and OS APIs.
 /// </remarks>
 internal sealed class SettingsPageViewModel : INavigationAware, IDisposable, INotifyPropertyChanged
 {
@@ -31,9 +32,11 @@ internal sealed class SettingsPageViewModel : INavigationAware, IDisposable, INo
 
     private readonly ISettingsStore _store;
     private readonly IAppCommands _appCommands;
+    private readonly SettingsWriteOrigin _origin;
 
     private bool _loading;
     private bool _subscribed;
+    private long _lastAppliedSettingsVersion = -1;
 
     private bool _autoStart;
     private bool _globalHotkeyEnabled;
@@ -58,6 +61,7 @@ public SettingsPageViewModel(ISettingsStore store, IAppCommands appCommands)
     {
         _store = store ?? throw new ArgumentNullException(nameof(store));
         _appCommands = appCommands ?? throw new ArgumentNullException(nameof(appCommands));
+        _origin = _store.CreateOrigin();
     }
 
     public event PropertyChangedEventHandler? PropertyChanged;
@@ -93,7 +97,7 @@ public bool AutoStart
 
     private async Task ApplyAutoStartAsync(bool value)
     {
-        if (await _appCommands.ApplyAutoStart(value))
+        if (await _appCommands.ApplyAutoStart(_origin, value))
         {
             RaiseSaved();
         }
@@ -223,12 +227,13 @@ public bool ShowChatToolCalls
     public void Activate(object? parameter)
     {
         IsActive = true;
-        LoadFromStore();
         if (!_subscribed)
         {
             _store.Changed += OnStoreChanged;
             _subscribed = true;
         }
+
+        LoadCurrentFromStore();
     }
 
     public void Deactivate()
@@ -247,16 +252,60 @@ public void Dispose()
         IsDisposed = true;
     }
 
-    private void OnStoreChanged(object? sender, EventArgs e)
+    private void OnStoreChanged(object? sender, SettingsChangedEventArgs e)
     {
-        LoadFromStore();
-        ExternalChanged?.Invoke(this, EventArgs.Empty);
+        if (!IsActive || !TryAdvanceSettingsVersion(e.Version))
+        {
+            return;
+        }
+
+        if (ApplySettingsSnapshotIfCurrent(e.Snapshot))
+        {
+            if (!ReferenceEquals(e.Origin, _origin))
+            {
+                ExternalChanged?.Invoke(this, EventArgs.Empty);
+            }
+        }
     }
 
     /// <summary>Reloads all bound properties from the store snapshot without persisting.</summary>
-    private void LoadFromStore()
+    private void LoadCurrentFromStore()
+    {
+        var snapshot = _store.Current;
+        TryAdvanceSettingsVersion(snapshot.Version);
+        ApplySettingsSnapshotIfCurrent(snapshot);
+    }
+
+    private bool ApplySettingsSnapshotIfCurrent(SettingsSnapshot snapshot)
+    {
+        if (snapshot.Version != Volatile.Read(ref _lastAppliedSettingsVersion))
+        {
+            return false;
+        }
+
+        LoadFromStore(snapshot);
+        return snapshot.Version == Volatile.Read(ref _lastAppliedSettingsVersion);
+    }
+
+    private bool TryAdvanceSettingsVersion(long version)
+    {
+        while (true)
+        {
+            var current = Volatile.Read(ref _lastAppliedSettingsVersion);
+            if (version <= current)
+            {
+                return false;
+            }
+
+            if (Interlocked.CompareExchange(ref _lastAppliedSettingsVersion, version, current) == current)
+            {
+                return true;
+            }
+        }
+    }
+
+    private void LoadFromStore(SettingsSnapshot s)
     {
-        var s = _store.Current;
         _loading = true;
         try
         {
@@ -294,7 +343,7 @@ private void Persist(Action<ISettingsEditor> edit)
             return;
         }
 
-        _store.Update(edit);
+        _store.Update(_origin, edit);
         _appCommands.NotifySettingsSaved();
         RaiseSaved();
     }
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs
index 5600d73a7..74ad4dca1 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs
@@ -1,28 +1,27 @@
+using System.Threading;
 using OpenClawTray.Services;
 
 namespace OpenClawTray.Presentation;
 
 /// <summary>
 /// Default <see cref="ISettingsStore"/> backed by the App-owned <see cref="SettingsManager"/>.
-/// It does not own the manager's lifetime (App does); it only reads/writes it and republishes
-/// the manager's <c>Saved</c> event as <see cref="Changed"/> with self-origination suppressed
-/// and UI-thread affinity.
+/// It serializes store-managed mutate/save operations, publishes a typed versioned change event
+/// for every save, tags store-managed saves with an explicit writer token, and republishes
+/// external <see cref="SettingsManager.Save"/> calls with <see langword="null"/> origin.
 /// </summary>
-/// <remarks>
-/// Self-origination is tracked with a <c>[ThreadStatic]</c> depth: <see cref="SettingsManager.Save"/>
-/// raises <c>Saved</c> synchronously on the calling thread inside <see cref="Update"/> or a
-/// <see cref="BeginSelfWrite"/> scope, so the handler observes a non-zero depth on that thread and
-/// suppresses the echo. A save from any other source (another surface, onboarding, a background
-/// writer) runs with depth zero and is republished as <see cref="Changed"/>, marshaled onto the UI
-/// thread via <see cref="IUiDispatcher"/>.
-/// </remarks>
 internal sealed class SettingsStore : ISettingsStore
 {
-    [ThreadStatic]
-    private static int t_selfUpdateDepth;
-
     private readonly SettingsManager _settings;
     private readonly IUiDispatcher _dispatcher;
+    private readonly object _updateGate = new();
+    private readonly object _snapshotGate = new();
+    private readonly object _originGate = new();
+
+    private long _nextOriginId;
+    private long _version;
+    private SettingsWriteOrigin? _activeOrigin;
+    private int _activeOriginThreadId;
+    private bool _disposed;
 
     public SettingsStore(SettingsManager settings, IUiDispatcher dispatcher)
     {
@@ -31,84 +30,154 @@ public SettingsStore(SettingsManager settings, IUiDispatcher dispatcher)
         _settings.Saved += OnManagerSaved;
     }
 
-    public event EventHandler? Changed;
+    public event EventHandler<SettingsChangedEventArgs>? Changed;
 
-    public SettingsSnapshot Current => new()
+    public SettingsSnapshot Current
     {
-        AutoStart = _settings.AutoStart,
-        GlobalHotkeyEnabled = _settings.GlobalHotkeyEnabled,
-        UseLegacyWebChat = _settings.UseLegacyWebChat,
-        ShowNotifications = _settings.ShowNotifications,
-        NotificationSound = _settings.NotificationSound,
-        AppTheme = _settings.AppTheme,
-        ShowDiagnosticsEffective = _settings.ShowDiagnosticsEffective,
-        NotifyHealth = _settings.NotifyHealth,
-        NotifyUrgent = _settings.NotifyUrgent,
-        NotifyReminder = _settings.NotifyReminder,
-        NotifyEmail = _settings.NotifyEmail,
-        NotifyCalendar = _settings.NotifyCalendar,
-        NotifyBuild = _settings.NotifyBuild,
-        NotifyStock = _settings.NotifyStock,
-        NotifyInfo = _settings.NotifyInfo,
-        ScreenRecordingConsentGiven = _settings.ScreenRecordingConsentGiven,
-        CameraRecordingConsentGiven = _settings.CameraRecordingConsentGiven,
-        ShowChatToolCalls = _settings.ShowChatToolCalls,
-    };
+        get
+        {
+            ThrowIfDisposed();
+            lock (_snapshotGate)
+            {
+                return CreateSnapshot(_version);
+            }
+        }
+    }
+
+    public SettingsWriteOrigin CreateOrigin()
+    {
+        ThrowIfDisposed();
+        return new SettingsWriteOrigin(Interlocked.Increment(ref _nextOriginId));
+    }
 
-    public void Update(Action<ISettingsEditor> edit)
+    public void Update(SettingsWriteOrigin? origin, Action<ISettingsEditor> edit)
     {
+        ThrowIfDisposed();
         ArgumentNullException.ThrowIfNull(edit);
 
-        using (BeginSelfWrite())
+        lock (_updateGate)
         {
-            edit(new Editor(_settings));
-            _settings.Save();
+            BeginSaveOrigin(origin);
+            try
+            {
+                edit(new Editor(_settings));
+                _settings.Save();
+            }
+            finally
+            {
+                EndSaveOrigin();
+            }
         }
     }
 
-    public IDisposable BeginSelfWrite()
+    public void Dispose()
     {
-        t_selfUpdateDepth++;
-        return new SelfWriteScope();
+        if (_disposed)
+        {
+            return;
+        }
+
+        _settings.Saved -= OnManagerSaved;
+        _disposed = true;
     }
 
     private void OnManagerSaved(object? sender, EventArgs e)
     {
-        // Saved fires synchronously on the thread that called Save(). When that is our own
-        // Update or an App-owned self-write on this thread, the depth is non-zero and the echo
-        // is suppressed.
-        if (t_selfUpdateDepth > 0)
+        var origin = ConsumeMatchingOrigin();
+        SettingsChangedEventArgs args;
+        lock (_snapshotGate)
         {
-            return;
+            var version = ++_version;
+            args = new SettingsChangedEventArgs(origin, CreateSnapshot(version));
         }
 
         if (_dispatcher.HasThreadAccess)
         {
-            Changed?.Invoke(this, EventArgs.Empty);
+            Changed?.Invoke(this, args);
         }
         else
         {
-            _dispatcher.TryEnqueue(() => Changed?.Invoke(this, EventArgs.Empty));
+            _dispatcher.TryEnqueue(() => Changed?.Invoke(this, args));
         }
     }
 
-    /// <summary>Decrements the self-write depth on dispose. Created and disposed on the same thread.</summary>
-    private sealed class SelfWriteScope : IDisposable
+    private void BeginSaveOrigin(SettingsWriteOrigin? origin)
     {
-        private bool _disposed;
+        lock (_originGate)
+        {
+            _activeOrigin = origin;
+            _activeOriginThreadId = Environment.CurrentManagedThreadId;
+        }
+    }
+
+    private void EndSaveOrigin()
+    {
+        lock (_originGate)
+        {
+            _activeOrigin = null;
+            _activeOriginThreadId = 0;
+        }
+    }
 
-        public void Dispose()
+    private SettingsWriteOrigin? ConsumeMatchingOrigin()
+    {
+        lock (_originGate)
         {
-            if (_disposed)
+            if (_activeOriginThreadId != Environment.CurrentManagedThreadId)
             {
-                return;
+                return null;
             }
 
-            _disposed = true;
-            t_selfUpdateDepth--;
+            var origin = _activeOrigin;
+            _activeOrigin = null;
+            _activeOriginThreadId = 0;
+            return origin;
         }
     }
 
+    private SettingsSnapshot CreateSnapshot(long version) => new()
+    {
+        Version = version,
+        AutoStart = _settings.AutoStart,
+        GlobalHotkeyEnabled = _settings.GlobalHotkeyEnabled,
+        UseLegacyWebChat = _settings.UseLegacyWebChat,
+        ShowNotifications = _settings.ShowNotifications,
+        NotificationSound = _settings.NotificationSound,
+        AppTheme = _settings.AppTheme,
+        ShowDiagnosticsEffective = _settings.ShowDiagnosticsEffective,
+        NotifyHealth = _settings.NotifyHealth,
+        NotifyUrgent = _settings.NotifyUrgent,
+        NotifyReminder = _settings.NotifyReminder,
+        NotifyEmail = _settings.NotifyEmail,
+        NotifyCalendar = _settings.NotifyCalendar,
+        NotifyBuild = _settings.NotifyBuild,
+        NotifyStock = _settings.NotifyStock,
+        NotifyInfo = _settings.NotifyInfo,
+        EnableNodeMode = _settings.EnableNodeMode,
+        EnableMcpServer = _settings.EnableMcpServer,
+        NodeSystemRunEnabled = _settings.NodeSystemRunEnabled,
+        NodeBrowserProxyEnabled = _settings.NodeBrowserProxyEnabled,
+        NodeCameraEnabled = _settings.NodeCameraEnabled,
+        NodeCanvasEnabled = _settings.NodeCanvasEnabled,
+        NodeScreenEnabled = _settings.NodeScreenEnabled,
+        NodeLocationEnabled = _settings.NodeLocationEnabled,
+        NodeTtsEnabled = _settings.NodeTtsEnabled,
+        NodeSttEnabled = _settings.NodeSttEnabled,
+        SttModelName = _settings.SttModelName,
+        TtsProvider = _settings.TtsProvider,
+        TtsPiperVoiceId = _settings.TtsPiperVoiceId,
+        TtsElevenLabsApiKey = _settings.TtsElevenLabsApiKey,
+        TtsElevenLabsVoiceId = _settings.TtsElevenLabsVoiceId,
+        ScreenRecordingConsentGiven = _settings.ScreenRecordingConsentGiven,
+        CameraRecordingConsentGiven = _settings.CameraRecordingConsentGiven,
+        ShowChatToolCalls = _settings.ShowChatToolCalls,
+    };
+
+    private void ThrowIfDisposed()
+    {
+        ObjectDisposedException.ThrowIf(_disposed, this);
+    }
+
     private sealed class Editor : ISettingsEditor
     {
         private readonly SettingsManager _settings;
@@ -130,8 +199,19 @@ private sealed class Editor : ISettingsEditor
         public bool NotifyBuild { set => _settings.NotifyBuild = value; }
         public bool NotifyStock { set => _settings.NotifyStock = value; }
         public bool NotifyInfo { set => _settings.NotifyInfo = value; }
+        public bool EnableNodeMode { set => _settings.EnableNodeMode = value; }
+        public bool EnableMcpServer { set => _settings.EnableMcpServer = value; }
+        public bool NodeSystemRunEnabled { set => _settings.NodeSystemRunEnabled = value; }
+        public bool NodeBrowserProxyEnabled { set => _settings.NodeBrowserProxyEnabled = value; }
+        public bool NodeCameraEnabled { set => _settings.NodeCameraEnabled = value; }
+        public bool NodeCanvasEnabled { set => _settings.NodeCanvasEnabled = value; }
+        public bool NodeScreenEnabled { set => _settings.NodeScreenEnabled = value; }
+        public bool NodeLocationEnabled { set => _settings.NodeLocationEnabled = value; }
+        public bool NodeTtsEnabled { set => _settings.NodeTtsEnabled = value; }
+        public bool NodeSttEnabled { set => _settings.NodeSttEnabled = value; }
         public bool ScreenRecordingConsentGiven { set => _settings.ScreenRecordingConsentGiven = value; }
         public bool CameraRecordingConsentGiven { set => _settings.CameraRecordingConsentGiven = value; }
+        public bool VoiceTtsEnabled { set => _settings.VoiceTtsEnabled = value; }
         public bool ShowChatToolCalls { set => _settings.ShowChatToolCalls = value; }
     }
 }
diff --git a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs
index 26ad1f36b..34e7d5d6f 100644
--- a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs
@@ -1,3 +1,5 @@
+using OpenClawTray.Presentation;
+
 namespace OpenClawTray.Services;
 
 /// <summary>
@@ -18,6 +20,6 @@ internal interface IAppCommands
     void ShowGatewayWizard();
     void ShowConnectionStatus();
     void NotifySettingsSaved();
-    Task<bool> ApplyAutoStart(bool autoStart);
+    Task<bool> ApplyAutoStart(SettingsWriteOrigin origin, bool autoStart);
     Task<bool> ResendOpenTelemetryProbeAsync();
 }
diff --git a/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs b/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs
index bbc494930..4c5ea7628 100644
--- a/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs
@@ -7,10 +7,25 @@ internal readonly record struct McpStartupNotificationPlan(bool ShouldShow, stri
     public bool ShouldDismiss => !ShouldShow;
 }
 
+internal enum McpCapabilityEnablePlan
+{
+    ReuseCurrent,
+    RebuildFromCurrentSettings,
+}
+
 internal static class McpRuntimeStatePolicy
 {
     public const string DefaultStartupError = "Local MCP server did not start.";
 
+    public static McpCapabilityEnablePlan PlanCapabilityEnable(
+        bool hasGatewayClient,
+        bool hasCapabilities)
+    {
+        return !hasGatewayClient || !hasCapabilities
+            ? McpCapabilityEnablePlan.RebuildFromCurrentSettings
+            : McpCapabilityEnablePlan.ReuseCurrent;
+    }
+
     public static McpStartupNotificationPlan PlanStartupNotification(
         bool enableMcpServer,
         bool isMcpRunning,
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index 15da60f68..906300a01 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -962,46 +962,67 @@ public string ResetMcpToken()
     /// </summary>
     public void SetMcpEnabled(bool enabled)
     {
-        _enableMcpServer = enabled;
-
-        if (enabled)
+        lock (_clientLock)
         {
-            if (_mcpServer != null) return; // already running
-
-            _logger.Info("[MCP] SetMcpEnabled(true) — starting MCP server");
-            _mcpStartupError = null;
+            _enableMcpServer = enabled;
 
-            bool needsCapabilities;
-            lock (_capabilitiesLock) { needsCapabilities = _capabilities.Count == 0; }
-            try
+            if (enabled)
             {
-                if (needsCapabilities)
+                if (_mcpServer != null) return; // already running
+
+                _logger.Info("[MCP] SetMcpEnabled(true) — starting MCP server");
+                _mcpStartupError = null;
+
+                McpCapabilityEnablePlan capabilityPlan;
+                lock (_capabilitiesLock)
                 {
-                    RegisterCapabilities();
+                    capabilityPlan = McpRuntimeStatePolicy.PlanCapabilityEnable(
+                        hasGatewayClient: _nodeClient != null,
+                        hasCapabilities: _capabilities.Count != 0);
                 }
-                else
+
+                try
                 {
-                    StartMcpServer();
+                    if (capabilityPlan == McpCapabilityEnablePlan.RebuildFromCurrentSettings)
+                    {
+                        RegisterCapabilities();
+                    }
+                    else
+                    {
+                        StartMcpServer();
+                    }
+                }
+
+                catch (Exception ex)
+                {
+                    SetMcpStartupFailure(ex, "MCP enable");
                 }
-            }
-            catch (Exception ex)
-            {
-                SetMcpStartupFailure(ex, "MCP enable");
-            }
 
-            if (_mcpServer == null && string.IsNullOrWhiteSpace(_mcpStartupError))
+                if (_mcpServer == null && string.IsNullOrWhiteSpace(_mcpStartupError))
+                {
+                    _mcpStartupError = "MCP server startup failed: listener did not start.";
+                    _logger.Error($"[MCP] {_mcpStartupError}");
+                }
+            }
+            else
             {
-                _mcpStartupError = "MCP server startup failed: listener did not start.";
-                _logger.Error($"[MCP] {_mcpStartupError}");
+                _logger.Info("[MCP] SetMcpEnabled(false) — stopping MCP server");
+                // Always call StopMcpServer to clear stale startup errors even
+                // if the server isn't running. StopMcpServer is lock-protected
+                // and handles _mcpServer == null safely.
+                StopMcpServer();
             }
         }
-        else
+    }
+
+    public void RefreshMcpOnlyCapabilities()
+    {
+        lock (_clientLock)
         {
-            _logger.Info("[MCP] SetMcpEnabled(false) — stopping MCP server");
-            // Always call StopMcpServer to clear stale startup errors even
-            // if the server isn't running. StopMcpServer is lock-protected
-            // and handles _mcpServer == null safely.
-            StopMcpServer();
+            if (!_enableMcpServer || _mcpServer == null || _nodeClient != null)
+                return;
+
+            RegisterCapabilities();
         }
     }
 
diff --git a/src/OpenClaw.Tray.WinUI/Services/SpeechSetupReadiness.cs b/src/OpenClaw.Tray.WinUI/Services/SpeechSetupReadiness.cs
index a812b1be9..8ad2714d3 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SpeechSetupReadiness.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SpeechSetupReadiness.cs
@@ -1,3 +1,4 @@
+using OpenClaw.Shared;
 using OpenClaw.Shared.Audio;
 using OpenClaw.Shared.Capabilities;
 
@@ -41,4 +42,31 @@ public static bool IsConfiguredTtsProviderSetupRequired(SettingsManager settings
 
         return true;
     }
+
+    public static bool IsConfiguredSttModelSetupRequired(SettingsManager settings) =>
+        IsConfiguredSttModelSetupRequired(
+            settings,
+            SettingsManager.SettingsDirectoryPath,
+            new AppLogger());
+
+    internal static bool IsConfiguredSttModelSetupRequired(
+        SettingsManager settings,
+        string dataDirectory,
+        IOpenClawLogger logger)
+    {
+        ArgumentNullException.ThrowIfNull(settings);
+        ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
+        ArgumentNullException.ThrowIfNull(logger);
+
+        var modelName = settings.SttModelName?.Trim();
+        if (string.IsNullOrWhiteSpace(modelName)
+            || !WhisperModelManager.AvailableModels.Any(model =>
+                string.Equals(model.Name, modelName, StringComparison.OrdinalIgnoreCase)))
+        {
+            return true;
+        }
+
+        var models = new WhisperModelManager(dataDirectory, logger);
+        return !models.IsModelDownloaded(modelName);
+    }
 }
diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
index 63511ec2a..4609e2396 100644
--- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
@@ -7,6 +7,7 @@
 using OpenClaw.Shared;
 using OpenClawTray.Chat;
 using OpenClawTray.Helpers;
+using OpenClawTray.Presentation;
 using OpenClawTray.Windows;
 using System;
 using System.Collections.Frozen;
@@ -18,7 +19,7 @@ namespace OpenClawTray.Services;
 
 internal sealed record TrayMenuCallbacks(
     Action<string> DispatchAction,
-    Action SaveAndReconnect,
+    Action<string, Action<ISettingsEditor>, Action<SettingsManager>> UpdatePermissionAndReconnect,
     Action<ToggleSwitch> TrackConnectionToggle,
     Func<bool> IsConnectionToggleSuspended);
 
@@ -1488,38 +1489,64 @@ private List<TrayMenuFlyoutItem> BuildPermissionsFlyoutItems(SettingsManager set
         };
 
         AddPermToggle(items, "Windows node", FluentIconCatalog.System,
-            "Run OpenClaw as a local node on this PC",
-            () => settings.EnableNodeMode, v => settings.EnableNodeMode = v);
+            "Run OpenClaw as a local node on this PC", nameof(SettingsManager.EnableNodeMode),
+            () => settings.EnableNodeMode,
+            (edit, v) => edit.EnableNodeMode = v,
+            (manager, v) => manager.EnableNodeMode = v);
         AddPermToggle(items, "System tools", FluentIconCatalog.Terminal,
-            "Let agents run shell commands and scripts on this PC",
-            () => settings.NodeSystemRunEnabled, v => settings.NodeSystemRunEnabled = v);
+            "Let agents run shell commands and scripts on this PC", nameof(SettingsManager.NodeSystemRunEnabled),
+            () => settings.NodeSystemRunEnabled,
+            (edit, v) => edit.NodeSystemRunEnabled = v,
+            (manager, v) => manager.NodeSystemRunEnabled = v);
         AddPermToggle(items, "Browser control", FluentIconCatalog.Browser,
-            "Let agents drive web browsers via proxy",
-            () => settings.NodeBrowserProxyEnabled, v => settings.NodeBrowserProxyEnabled = v);
+            "Let agents drive web browsers via proxy", nameof(SettingsManager.NodeBrowserProxyEnabled),
+            () => settings.NodeBrowserProxyEnabled,
+            (edit, v) => edit.NodeBrowserProxyEnabled = v,
+            (manager, v) => manager.NodeBrowserProxyEnabled = v);
         AddPermToggle(items, "Camera", FluentIconCatalog.Camera,
-            "Allow webcam capture during sessions",
-            () => settings.NodeCameraEnabled, v => settings.NodeCameraEnabled = v);
+            "Allow webcam capture during sessions", nameof(SettingsManager.NodeCameraEnabled),
+            () => settings.NodeCameraEnabled,
+            (edit, v) => edit.NodeCameraEnabled = v,
+            (manager, v) => manager.NodeCameraEnabled = v);
         AddPermToggle(items, "Canvas", FluentIconCatalog.Canvas,
-            "Render generated HTML canvases in chat",
-            () => settings.NodeCanvasEnabled, v => settings.NodeCanvasEnabled = v);
+            "Render generated HTML canvases in chat", nameof(SettingsManager.NodeCanvasEnabled),
+            () => settings.NodeCanvasEnabled,
+            (edit, v) => edit.NodeCanvasEnabled = v,
+            (manager, v) => manager.NodeCanvasEnabled = v);
         AddPermToggle(items, "Screen capture", FluentIconCatalog.Screen,
-            "Share what's on your screen with the agent",
-            () => settings.NodeScreenEnabled, v => settings.NodeScreenEnabled = v);
+            "Share what's on your screen with the agent", nameof(SettingsManager.NodeScreenEnabled),
+            () => settings.NodeScreenEnabled,
+            (edit, v) => edit.NodeScreenEnabled = v,
+            (manager, v) => manager.NodeScreenEnabled = v);
         AddPermToggle(items, "Location", FluentIconCatalog.Location,
-            "Share this device's location",
-            () => settings.NodeLocationEnabled, v => settings.NodeLocationEnabled = v);
+            "Share this device's location", nameof(SettingsManager.NodeLocationEnabled),
+            () => settings.NodeLocationEnabled,
+            (edit, v) => edit.NodeLocationEnabled = v,
+            (manager, v) => manager.NodeLocationEnabled = v);
         AddPermToggle(items, "Voice (TTS)", FluentIconCatalog.Voice,
-            "Read responses out loud",
-            () => settings.NodeTtsEnabled, v => settings.NodeTtsEnabled = v);
+            "Read responses out loud", nameof(SettingsManager.NodeTtsEnabled),
+            () => settings.NodeTtsEnabled,
+            (edit, v) => edit.NodeTtsEnabled = v,
+            (manager, v) => manager.NodeTtsEnabled = v);
         AddPermToggle(items, "Speech-to-text (STT)", FluentIconCatalog.Speech,
-            "Dictate input by speaking",
-            () => settings.NodeSttEnabled, v => settings.NodeSttEnabled = v);
+            "Dictate input by speaking", nameof(SettingsManager.NodeSttEnabled),
+            () => settings.NodeSttEnabled,
+            (edit, v) => edit.NodeSttEnabled = v,
+            (manager, v) => manager.NodeSttEnabled = v);
 
         items.Add(new() { CustomContent = new Border { Height = 10 } });
         return items;
     }
 
-    private void AddPermToggle(List<TrayMenuFlyoutItem> items, string label, string iconGlyph, string description, Func<bool> get, Action<bool> set)
+    private void AddPermToggle(
+        List<TrayMenuFlyoutItem> items,
+        string label,
+        string iconGlyph,
+        string description,
+        string settingName,
+        Func<bool> get,
+        Action<ISettingsEditor, bool> update,
+        Action<SettingsManager, bool> fallbackUpdate)
     {
         var on = get();
         var actionId = $"perm-toggle|{label}";
@@ -1534,8 +1561,11 @@ private void AddPermToggle(List<TrayMenuFlyoutItem> items, string label, string
         });
         _permToggleActions[actionId] = () =>
         {
-            set(!get());
-            _callbacks.SaveAndReconnect();
+            var nextValue = !get();
+            _callbacks.UpdatePermissionAndReconnect(
+                settingName,
+                edit => update(edit, nextValue),
+                settings => fallbackUpdate(settings, nextValue));
         };
     }
 
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 0219a8696..fc29c13f9 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -8,6 +8,7 @@
 using OpenClaw.Shared;
 using OpenClawTray.Helpers;
 using OpenClawTray.Pages;
+using OpenClawTray.Presentation;
 using OpenClawTray.Services;
 using OpenClawTray.ViewModels;
 using System;
@@ -39,6 +40,7 @@ private static TaskCompletionSource<bool> CreateCompletedContentReady()
     private readonly AppNotificationBannerState _appNotificationBannerState = new();
     private AppNotificationSnapshot? _lastAppNotificationSnapshot;
     private AppNotification? _currentAppNotification;
+    private SettingsWriteOrigin? _commandPaletteSettingsOrigin;
     private bool _suppressAppNotificationClosed;
     private bool _appNotificationActionShowsMore;
 
@@ -1116,7 +1118,7 @@ private void InitializeCurrentPage()
                 }
                 break;
             case InstancesPage instances: instances.Initialize(); break;
-            case PermissionsPage permissions: permissions.Initialize(); break;
+            case PermissionsPage: break;
             case SandboxPage sandbox: sandbox.Initialize(); break;
             case VoiceSettingsPage voice: voice.Initialize(CurrentApp.VoiceService); break;
             case AgentEventsPage agentEvents:
@@ -1333,31 +1335,51 @@ internal List<CommandItem> BuildCommandList()
             {
                 Icon = "🔌", Title = LocalizationHelper.GetString("Command_ToggleNodeMode_Title"),
                 Subtitle = settings.EnableNodeMode ? on : off,
-                Execute = () => { settings.EnableNodeMode = !settings.EnableNodeMode; settings.Save(); RaiseSettingsSaved(); }
+                Execute = () => ToggleCommandPalettePermission(
+                    nameof(SettingsManager.EnableNodeMode),
+                    () => settings.EnableNodeMode,
+                    (edit, value) => edit.EnableNodeMode = value,
+                    (manager, value) => manager.EnableNodeMode = value)
             });
             commands.Add(new CommandItem
             {
                 Icon = "📷", Title = LocalizationHelper.GetString("Command_ToggleCamera_Title"),
                 Subtitle = settings.NodeCameraEnabled ? on : off,
-                Execute = () => { settings.NodeCameraEnabled = !settings.NodeCameraEnabled; settings.Save(); RaiseSettingsSaved(); }
+                Execute = () => ToggleCommandPalettePermission(
+                    nameof(SettingsManager.NodeCameraEnabled),
+                    () => settings.NodeCameraEnabled,
+                    (edit, value) => edit.NodeCameraEnabled = value,
+                    (manager, value) => manager.NodeCameraEnabled = value)
             });
             commands.Add(new CommandItem
             {
                 Icon = "🎨", Title = LocalizationHelper.GetString("Command_ToggleCanvas_Title"),
                 Subtitle = settings.NodeCanvasEnabled ? on : off,
-                Execute = () => { settings.NodeCanvasEnabled = !settings.NodeCanvasEnabled; settings.Save(); RaiseSettingsSaved(); }
+                Execute = () => ToggleCommandPalettePermission(
+                    nameof(SettingsManager.NodeCanvasEnabled),
+                    () => settings.NodeCanvasEnabled,
+                    (edit, value) => edit.NodeCanvasEnabled = value,
+                    (manager, value) => manager.NodeCanvasEnabled = value)
             });
             commands.Add(new CommandItem
             {
                 Icon = "🖥️", Title = LocalizationHelper.GetString("Command_ToggleScreenCapture_Title"),
                 Subtitle = settings.NodeScreenEnabled ? on : off,
-                Execute = () => { settings.NodeScreenEnabled = !settings.NodeScreenEnabled; settings.Save(); RaiseSettingsSaved(); }
+                Execute = () => ToggleCommandPalettePermission(
+                    nameof(SettingsManager.NodeScreenEnabled),
+                    () => settings.NodeScreenEnabled,
+                    (edit, value) => edit.NodeScreenEnabled = value,
+                    (manager, value) => manager.NodeScreenEnabled = value)
             });
             commands.Add(new CommandItem
             {
                 Icon = "🌐", Title = LocalizationHelper.GetString("Command_ToggleBrowserControl_Title"),
                 Subtitle = settings.NodeBrowserProxyEnabled ? on : off,
-                Execute = () => { settings.NodeBrowserProxyEnabled = !settings.NodeBrowserProxyEnabled; settings.Save(); RaiseSettingsSaved(); }
+                Execute = () => ToggleCommandPalettePermission(
+                    nameof(SettingsManager.NodeBrowserProxyEnabled),
+                    () => settings.NodeBrowserProxyEnabled,
+                    (edit, value) => edit.NodeBrowserProxyEnabled = value,
+                    (manager, value) => manager.NodeBrowserProxyEnabled = value)
             });
         }
 
@@ -1380,6 +1402,42 @@ internal List<CommandItem> BuildCommandList()
         return commands;
     }
 
+    private void ToggleCommandPalettePermission(
+        string settingName,
+        Func<bool> getCurrentValue,
+        Action<ISettingsEditor, bool> update,
+        Action<SettingsManager, bool> fallbackUpdate)
+    {
+        var nextValue = !getCurrentValue();
+        try
+        {
+            if (CurrentApp.SettingsStore is { } store)
+            {
+                _commandPaletteSettingsOrigin ??= store.CreateOrigin();
+                store.Update(_commandPaletteSettingsOrigin, edit => update(edit, nextValue));
+            }
+            else
+            {
+                var settings = CurrentApp.SettingsOrNull;
+                if (settings == null)
+                {
+                    Services.Logger.Warn($"[HubWindow] Could not persist {settingName} because settings are unavailable.");
+                    return;
+                }
+
+                Services.Logger.Warn($"[HubWindow] ISettingsStore unavailable for {settingName}. Falling back to SettingsManager.Save.");
+                fallbackUpdate(settings, nextValue);
+                settings.Save();
+            }
+
+            RaiseSettingsSaved();
+        }
+        catch (Exception ex)
+        {
+            Services.Logger.Warn($"[HubWindow] Failed to persist {settingName}: {ex.Message}");
+        }
+    }
+
     private void ExecuteCommand(CommandItem cmd)
     {
         if (cmd.Execute != null)
diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs
index e48691a2c..5620f9a6e 100644
--- a/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs
+++ b/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs
@@ -1,10 +1,12 @@
 using System;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using System.Security.Cryptography;
 using System.Text;
 using System.Text.Json;
+using System.Threading;
 using System.Threading.Tasks;
 using OpenClaw.Shared;
 using OpenClaw.Shared.ExecApprovals;
@@ -12,9 +14,8 @@
 
 namespace OpenClaw.Shared.Tests;
 
-// Tests for PR4: ExecApprovalsStore read path.
 // Coverage: deserialization, normalization, cascade resolution, malformed/version guards,
-// default-deny semantics, and ensureFile behavior.
+// default-deny semantics, persistence, and observation behavior.
 public class ExecApprovalsStoreTests : IDisposable
 {
     private readonly string _dir;
@@ -1322,6 +1323,392 @@ public async Task ResolveAsync_TildePrefixedOpenClawHome_ExpandsRelativeToOsHome
             $"exec-approvals.json should be at {expectedFile}");
     }
 
+    [Fact]
+    public async Task GetSnapshotReadOnlyAsync_MissingStore_ReturnsDefaultSnapshotAndCreatesNothing()
+    {
+        var dataPath = Path.Combine(_dir, "readonly-missing");
+        var filePath = Path.Combine(dataPath, "exec-approvals.json");
+        using var store = StoreAt(dataPath);
+
+        var result = await store.GetSnapshotReadOnlyAsync();
+
+        Assert.True(result.IsSuccess);
+        Assert.NotNull(result.Snapshot);
+        Assert.False(result.Snapshot!.Exists);
+        Assert.Equal($"missing:{Hash(string.Empty)}", result.Snapshot.Hash);
+        Assert.Equal(ExecSecurity.Allowlist, result.Snapshot.File.Defaults!.Security);
+        Assert.Equal(ExecAsk.OnMiss, result.Snapshot.File.Defaults.Ask);
+        Assert.False(Directory.Exists(dataPath));
+        Assert.False(File.Exists(filePath));
+    }
+
+    [Fact]
+    public async Task GetSnapshotReadOnlyAsync_MalformedFile_ReturnsTypedFailureWithoutRewrite()
+    {
+        var dataPath = Path.Combine(_dir, "readonly-invalid");
+        Directory.CreateDirectory(dataPath);
+        var filePath = Path.Combine(dataPath, "exec-approvals.json");
+        WriteFile(filePath, "{ bad json }");
+        using var store = StoreAt(dataPath);
+
+        var result = await store.GetSnapshotReadOnlyAsync();
+
+        Assert.False(result.IsSuccess);
+        Assert.Null(result.Snapshot);
+        Assert.NotNull(result.Failure);
+        Assert.Equal(ExecApprovalsSnapshotFailureKind.MalformedJson, result.Failure!.Kind);
+        Assert.Equal("{ bad json }", File.ReadAllText(filePath));
+    }
+
+    [Fact]
+    public void Changed_SubscribeWithMissingDirectory_CreatesNothing()
+    {
+        var dataPath = Path.Combine(_dir, "observer-missing");
+        var filePath = Path.Combine(dataPath, "exec-approvals.json");
+        using var store = StoreAt(dataPath);
+        EventHandler<ExecApprovalsChangedEventArgs> handler = (_, _) => { };
+
+        store.Changed += handler;
+
+        Assert.False(Directory.Exists(dataPath));
+        Assert.False(File.Exists(filePath));
+
+        store.Changed -= handler;
+    }
+
+    [Fact]
+    public async Task Changed_SubscribeWithMissingDirectory_ObservesFirstExternalCreateExactlyOnce()
+    {
+        var dataPath = Path.Combine(_dir, "observer-external-create", "state");
+        var filePath = Path.Combine(dataPath, "exec-approvals.json");
+        using var store = StoreAt(dataPath);
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        await store.GetSnapshotReadOnlyAsync();
+
+        Directory.CreateDirectory(dataPath);
+        WriteAtomically(filePath, SerializeFile(CreateAllowlistFile("**/git.exe")));
+
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected the first external create event.");
+        await Task.Delay(250);
+
+        var change = Assert.Single(changes);
+        Assert.True(
+            change.Kind == ExecApprovalsChangeKind.SnapshotUpdated,
+            $"Expected a valid snapshot, got {change.Kind}: {change.Failure?.Kind} {change.Failure?.Message}");
+        Assert.NotNull(change.Snapshot);
+        Assert.Equal("**/git.exe", change.Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+    }
+
+    [Fact]
+    public async Task ReplaceAsync_MissingHashFromReadOnlySnapshot_CreatesFile()
+    {
+        var dataPath = Path.Combine(_dir, "missing-hash-write");
+        var filePath = Path.Combine(dataPath, "exec-approvals.json");
+        using var store = StoreAt(dataPath);
+        var readOnly = await store.GetSnapshotReadOnlyAsync();
+
+        var updated = await store.ReplaceAsync(
+            readOnly.Snapshot!.Hash,
+            CreateAllowlistFile("**/git.exe"),
+            origin: null);
+
+        Assert.NotNull(updated);
+        Assert.True(File.Exists(filePath));
+        Assert.True(updated!.Exists);
+        Assert.Single(store.ResolveReadOnly("main").Allowlist);
+    }
+
+    [Fact]
+    public async Task Changed_TwoOriginFilteredSubscribers_ReceiveOtherWriterExactlyOnce()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        var originA = store.CreateWriterOrigin();
+        var originB = store.CreateWriterOrigin();
+        var seenByA = 0;
+        var seenByB = 0;
+
+        store.Changed += (_, args) =>
+        {
+            changes.Enqueue(args);
+            if (!ReferenceEquals(args.Origin, originA))
+            {
+                Interlocked.Increment(ref seenByA);
+            }
+
+            if (!ReferenceEquals(args.Origin, originB))
+            {
+                Interlocked.Increment(ref seenByB);
+            }
+        };
+
+        var current = (await store.GetSnapshotReadOnlyAsync()).Snapshot!;
+        var first = await store.ReplaceAsync(current.Hash, CreateAllowlistFile("**/git.exe"), originA);
+        var second = await store.ReplaceAsync(first!.Hash, CreateAllowlistFile("**/rg.exe"), originB);
+
+        Assert.NotNull(second);
+        await WaitForConditionAsync(() => changes.Count == 2, "Expected two managed change events.");
+        await Task.Delay(250);
+
+        var published = changes.ToArray();
+        Assert.Equal(2, published.Length);
+        Assert.Same(originA, published[0].Origin);
+        Assert.Same(originB, published[1].Origin);
+        Assert.True(published[0].Sequence > 0);
+        Assert.True(published[1].Sequence > published[0].Sequence);
+        Assert.Equal(1, seenByA);
+        Assert.Equal(1, seenByB);
+    }
+
+    [Fact]
+    public async Task Changed_ManagedThenExternalEvents_HaveMonotonicSequences()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        var current = (await store.GetSnapshotReadOnlyAsync()).Snapshot!;
+
+        var managed = await store.ReplaceAsync(
+            current.Hash,
+            CreateAllowlistFile("**/git.exe"),
+            store.CreateWriterOrigin());
+        Assert.NotNull(managed);
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected the managed update event.");
+        await Task.Delay(250);
+
+        WriteAtomically(FilePath, SerializeFile(CreateAllowlistFile("**/rg.exe")));
+        await WaitForConditionAsync(() => changes.Count == 2, "Expected the external update event.");
+        await Task.Delay(250);
+
+        var published = changes.ToArray();
+        Assert.Equal(2, published.Length);
+        Assert.True(published[0].Sequence > 0);
+        Assert.True(published[1].Sequence > published[0].Sequence);
+        Assert.NotNull(published[1].Snapshot);
+        Assert.Equal("**/rg.exe", published[1].Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+    }
+
+    [Fact]
+    public async Task Changed_ExternalAtomicValidReplacement_RaisesExactlyOnce()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        await store.GetSnapshotReadOnlyAsync();
+
+        WriteAtomically(FilePath, SerializeFile(CreateAllowlistFile("**/git.exe")));
+
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected one external update event.");
+        await Task.Delay(250);
+
+        var change = Assert.Single(changes);
+        Assert.Equal(ExecApprovalsChangeKind.SnapshotUpdated, change.Kind);
+        Assert.NotNull(change.Snapshot);
+        Assert.Null(change.Failure);
+        Assert.Equal("**/git.exe", change.Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+    }
+
+    [Fact]
+    public async Task GetSnapshotReadOnlyAsync_ActiveSubscriber_DoesNotConsumePendingExternalChange()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        await store.GetSnapshotReadOnlyAsync();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+
+        WriteAtomically(FilePath, SerializeFile(CreateAllowlistFile("**/git.exe")));
+        var directRead = await store.GetSnapshotReadOnlyAsync();
+
+        Assert.Equal("**/git.exe", directRead.Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected the pending external update event.");
+        await Task.Delay(250);
+
+        var change = Assert.Single(changes);
+        Assert.Equal(ExecApprovalsChangeKind.SnapshotUpdated, change.Kind);
+        Assert.Equal("**/git.exe", change.Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+    }
+
+    [Fact]
+    public async Task Changed_ReactivateAfterUnobservedEdit_DoesNotSuppressRevertToPriorContent()
+    {
+        var originalJson = SerializeFile(CreateAllowlistFile("**/git.exe"));
+        WriteFile(originalJson);
+        using var store = Store();
+        await store.GetSnapshotReadOnlyAsync();
+        EventHandler<ExecApprovalsChangedEventArgs> firstHandler = (_, _) => { };
+        store.Changed += firstHandler;
+        store.Changed -= firstHandler;
+
+        WriteAtomically(FilePath, SerializeFile(CreateAllowlistFile("**/rg.exe")));
+
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        var reactivated = await store.GetSnapshotReadOnlyAsync();
+        Assert.Equal("**/rg.exe", reactivated.Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+
+        WriteAtomically(FilePath, originalJson);
+
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected the reverted external update event.");
+        await Task.Delay(250);
+
+        var change = Assert.Single(changes);
+        Assert.Equal("**/git.exe", change.Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+    }
+
+    [Fact]
+    public async Task Changed_ExternalCorruptThenValid_RaisesFailureThenRecovery()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        await store.GetSnapshotReadOnlyAsync();
+
+        WriteAtomically(FilePath, "{ bad json }");
+
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected one failure event.");
+        var failure = Assert.Single(changes);
+        Assert.Equal(ExecApprovalsChangeKind.SnapshotInvalid, failure.Kind);
+        Assert.NotNull(failure.Failure);
+        Assert.Equal(ExecApprovalsSnapshotFailureKind.MalformedJson, failure.Failure!.Kind);
+        Assert.Null(failure.Snapshot);
+        Assert.NotNull(failure.LastValidSnapshot);
+        Assert.True(failure.LastValidSnapshot!.Exists);
+
+        WriteAtomically(FilePath, SerializeFile(CreateAllowlistFile("**/rg.exe")));
+
+        await WaitForConditionAsync(() => changes.Count == 2, "Expected a recovery event.");
+        await Task.Delay(250);
+
+        var published = changes.ToArray();
+        Assert.Equal(2, published.Length);
+        Assert.Equal(ExecApprovalsChangeKind.SnapshotRecovered, published[1].Kind);
+        Assert.NotNull(published[1].Snapshot);
+        Assert.Equal("**/rg.exe", published[1].Snapshot!.File.Agents!["main"].Allowlist![0].Pattern);
+    }
+
+    [Fact]
+    public async Task AddAllowlistEntryAsync_RaisesChangedOnceWithoutWatcherEcho()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        await store.GetSnapshotReadOnlyAsync();
+
+        var result = await store.AddAllowlistEntryAsync("main", "**/git.exe");
+
+        Assert.True(result);
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected one add event.");
+        await Task.Delay(250);
+
+        var change = Assert.Single(changes);
+        Assert.Equal(ExecApprovalsChangeKind.SnapshotUpdated, change.Kind);
+        Assert.Null(change.Origin);
+        Assert.NotNull(change.Snapshot);
+        Assert.Single(change.Snapshot!.File.Agents!["main"].Allowlist!);
+    }
+
+    [Fact]
+    public async Task RecordAllowlistUseAsync_RaisesChangedOnceWithoutWatcherEcho()
+    {
+        WriteFile(SerializeFile(CreateAllowlistFile("**/git.exe")));
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        await store.GetSnapshotReadOnlyAsync();
+
+        var result = await store.RecordAllowlistUseAsync("main", "**/git.exe", "C:\\tools\\git.exe");
+
+        Assert.True(result);
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected one usage event.");
+        await Task.Delay(250);
+
+        var change = Assert.Single(changes);
+        Assert.Equal(ExecApprovalsChangeKind.SnapshotUpdated, change.Kind);
+        Assert.NotNull(change.Snapshot);
+        Assert.NotNull(change.Snapshot!.File.Agents!["main"].Allowlist![0].LastUsedAt);
+    }
+
+    [Fact]
+    public async Task Dispose_StopsObservation()
+    {
+        WriteFile(MinimalFile());
+        var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        await store.GetSnapshotReadOnlyAsync();
+
+        store.Dispose();
+        WriteAtomically(FilePath, SerializeFile(CreateAllowlistFile("**/git.exe")));
+        await Task.Delay(350);
+
+        Assert.Empty(changes);
+    }
+
+    [Fact]
+    public async Task ReplaceAsync_StaleHash_ReturnsNullAndDoesNotRaiseSuccess()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        var readOnly = await store.GetSnapshotReadOnlyAsync();
+
+        var first = await store.ReplaceAsync(
+            readOnly.Snapshot!.Hash,
+            CreateAllowlistFile("**/git.exe"),
+            store.CreateWriterOrigin());
+
+        Assert.NotNull(first);
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected the first managed change.");
+
+        var stale = await store.ReplaceAsync(
+            readOnly.Snapshot.Hash,
+            CreateAllowlistFile("**/rg.exe"),
+            store.CreateWriterOrigin());
+
+        Assert.Null(stale);
+        await Task.Delay(250);
+        Assert.Single(changes);
+    }
+
+    [Fact]
+    public async Task ReplaceAsync_ConcurrentDisjointWrites_OneStaleRejection()
+    {
+        WriteFile(MinimalFile());
+        using var store = Store();
+        var changes = new ConcurrentQueue<ExecApprovalsChangedEventArgs>();
+        store.Changed += (_, args) => changes.Enqueue(args);
+        var readOnly = await store.GetSnapshotReadOnlyAsync();
+        var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        async Task<ExecApprovalsSnapshot?> ReplaceAsync(string pattern, ExecApprovalsWriterOrigin origin)
+        {
+            await gate.Task;
+            return await store.ReplaceAsync(readOnly.Snapshot!.Hash, CreateAllowlistFile(pattern), origin);
+        }
+
+        var first = ReplaceAsync("**/git.exe", store.CreateWriterOrigin());
+        var second = ReplaceAsync("**/rg.exe", store.CreateWriterOrigin());
+        gate.SetResult();
+
+        var results = await Task.WhenAll(first, second);
+
+        Assert.Equal(1, results.Count(result => result is not null));
+        Assert.Equal(1, results.Count(result => result is null));
+        await WaitForConditionAsync(() => changes.Count == 1, "Expected one winning change.");
+        await Task.Delay(250);
+
+        Assert.Single(changes);
+        Assert.Single(store.ResolveReadOnly("main").Allowlist);
+    }
+
     // ── Helpers ───────────────────────────────────────────────────────────────
 
     private static string MinimalFile() => """{"version":1,"agents":{}}""";
@@ -1334,6 +1721,59 @@ private static string MinimalFileWithAgent(string agentId, string security) => $
           }
         }
         """;
+
+    private ExecApprovalsStore StoreAt(string dataPath) => new(dataPath, _log);
+
+    private static void WriteFile(string filePath, string json) => File.WriteAllText(filePath, json);
+
+    private static string SerializeFile(ExecApprovalsFile file) =>
+        JsonSerializer.Serialize(file, ExecApprovalsStore.JsonOptions);
+
+    private static ExecApprovalsFile CreateAllowlistFile(string pattern) =>
+        new()
+        {
+            Version = 1,
+            Agents = new Dictionary<string, ExecApprovalsAgent>
+            {
+                ["main"] = new ExecApprovalsAgent
+                {
+                    Security = ExecSecurity.Allowlist,
+                    Allowlist =
+                    [
+                        new ExecAllowlistEntry
+                        {
+                            Id = Guid.NewGuid(),
+                            Pattern = pattern,
+                        },
+                    ],
+                },
+            },
+        };
+
+    private static void WriteAtomically(string filePath, string json)
+    {
+        var directoryPath = Path.GetDirectoryName(filePath)!;
+        Directory.CreateDirectory(directoryPath);
+        var tempPath = Path.Combine(directoryPath, $".exec-approvals-test-{Guid.NewGuid():N}.tmp");
+        File.WriteAllText(tempPath, json);
+        File.Move(tempPath, filePath, overwrite: true);
+    }
+
+    private static async Task WaitForConditionAsync(Func<bool> condition, string message, int timeoutMs = 5000)
+    {
+        var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
+        while (DateTime.UtcNow < deadline)
+        {
+            if (condition())
+            {
+                return;
+            }
+
+            await Task.Delay(50);
+        }
+
+        Assert.True(condition(), message);
+    }
 }
 
 internal sealed class CapturingLogger : IOpenClawLogger
diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
index 2c6713b8f..28d56178e 100644
--- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
@@ -257,6 +257,77 @@ public void OnSettingsSaved_AppliesMcpStartupNotificationPlan()
             "McpRuntimeStatePolicy.PlanStartupNotification");
     }
 
+    [Fact]
+    public void McpOnlyCapabilityReload_RebuildsTheSharedCapabilityList()
+    {
+        var root = TestRepositoryPaths.GetRepositoryRoot();
+        var appSource = ReadAppSources();
+        var nodeServiceSource = File.ReadAllText(Path.Combine(
+            root,
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Services",
+            "NodeService.cs"));
+
+        var reconnect = ExtractMethod(appSource, "ReconnectWithSyncedBrowserProxyForward");
+        var refresh = ExtractMethod(nodeServiceSource, "RefreshMcpOnlyCapabilities");
+
+        AssertInOrder(
+            reconnect,
+            "SyncActiveGatewayBrowserProxyForward()",
+            "_nodeService?.RefreshMcpOnlyCapabilities()",
+            "_connectionManager?.ReconnectAsync()");
+        Assert.Contains("lock (_clientLock)", refresh);
+        Assert.Contains("if (!_enableMcpServer || _mcpServer == null || _nodeClient != null)", refresh);
+        Assert.Contains("RegisterCapabilities();", refresh);
+    }
+
+    [Fact]
+    public void McpRestart_RebuildsOnlyLocalTransportAndPreservesCapabilityOwners()
+    {
+        var root = TestRepositoryPaths.GetRepositoryRoot();
+        var source = File.ReadAllText(Path.Combine(
+            root,
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Services",
+            "NodeService.cs"));
+        var enable = ExtractMethod(source, "SetMcpEnabled");
+        var refresh = ExtractMethod(source, "RefreshMcpOnlyCapabilities");
+        var register = ExtractMethod(source, "RegisterCapabilities");
+        var start = ExtractMethod(source, "StartMcpServer");
+
+        AssertInOrder(
+            enable,
+            "lock (_clientLock)",
+            "lock (_capabilitiesLock)",
+            "McpRuntimeStatePolicy.PlanCapabilityEnable",
+            "McpCapabilityEnablePlan.RebuildFromCurrentSettings",
+            "RegisterCapabilities();");
+        Assert.Contains("hasGatewayClient: _nodeClient != null", enable);
+        Assert.Contains("hasCapabilities: _capabilities.Count != 0", enable);
+        Assert.Contains("StartMcpServer();", enable);
+        Assert.Contains("StopMcpServer();", enable);
+
+        Assert.Contains("lock (_clientLock)", refresh);
+        Assert.Contains("RegisterCapabilities();", refresh);
+        AssertInOrder(
+            register,
+            "lock (_capabilitiesLock)",
+            "_capabilities.Clear();",
+            "_execApprovalsV2Handler ??=",
+            "_textToSpeechService ??=",
+            "_voiceService ??=",
+            "} // end lock",
+            "StartMcpServer();");
+
+        Assert.DoesNotContain("_mcpOnlyCapabilities.Clear()", register);
+        AssertInOrder(
+            start,
+            "merged.AddRange(_capabilities)",
+            "merged.AddRange(_mcpOnlyCapabilities)");
+    }
+
     [Fact]
     public void AppStatus_ReportsNodeStateFromManagerSnapshot()
     {
@@ -425,15 +496,17 @@ public void ChatWebView_KeepsBaseChatUrlSeparateFromPendingSessionKey()
     public void PermissionsPage_ExecApprovals_UsesAppOwnedStoreWithCas()
     {
         var root = TestRepositoryPaths.GetRepositoryRoot();
-        var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs"));
+        var pageSource = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs"));
+        var viewModelSource = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Presentation", "PermissionsPageViewModel.cs"));
 
-        Assert.Contains("CurrentApp.ExecApprovalsStore.GetSnapshotAsync()", source);
-        Assert.Contains("CurrentApp.ExecApprovalsStore.ReplaceAsync(expectedHash, file)", source);
-        Assert.Contains("ExecPolicyMutationKind.AddRule", source);
-        Assert.Contains("ExecPolicyMutationKind.RemoveRule", source);
-        Assert.DoesNotContain("main.Allowlist = _policyRules", source);
-        Assert.DoesNotContain("Path.Combine(CurrentApp.DataDirectoryPath, \"exec-approvals.json\")", source);
-        Assert.DoesNotContain("File.WriteAllText(tmpPath", source);
+        Assert.Contains("_execApprovalsStore.GetSnapshotReadOnlyAsync()", viewModelSource);
+        Assert.Contains("_execApprovalsStore.ReplaceAsync(baseHash, workingFile, _execApprovalsOrigin)", viewModelSource);
+        Assert.Contains("ExecApprovalsMutationKind.AddRule", viewModelSource);
+        Assert.Contains("ExecApprovalsMutationKind.RemoveRule", viewModelSource);
+        Assert.DoesNotContain("CurrentApp.ExecApprovalsStore.GetSnapshot", pageSource);
+        Assert.DoesNotContain("CurrentApp.ExecApprovalsStore.ReplaceAsync", pageSource);
+        Assert.DoesNotContain("Path.Combine(CurrentApp.DataDirectoryPath, \"exec-approvals.json\")", pageSource);
+        Assert.DoesNotContain("File.WriteAllText(tmpPath", pageSource);
     }
 
     [Fact]
@@ -468,8 +541,8 @@ public void PermissionsPage_ExecPolicyRemoveButtons_HaveAccessibleNames()
 
         Assert.Contains("AutomationProperties.Name=\"{Binding RemoveRuleAutomationName}\"", xaml);
         Assert.Contains("AutomationProperties.AutomationId=\"{Binding RemoveRuleAutomationId}\"", xaml);
-        Assert.Contains("RemoveRuleAutomationName = $\"Remove rule {r.Pattern}\"", codeBehind);
-        Assert.Contains("RemoveRuleAutomationId = $\"RemoveExecPolicyRuleButton_{r.Index}\"", codeBehind);
+        Assert.Contains("RemoveRuleAutomationName = $\"Remove rule {rule.Pattern}\"", codeBehind);
+        Assert.Contains("RemoveRuleAutomationId = $\"RemoveExecPolicyRuleButton_{index}\"", codeBehind);
     }
 
     [Fact]
diff --git a/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs b/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs
index c87e46f6f..9d9d43ec3 100644
--- a/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs
+++ b/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs
@@ -115,7 +115,18 @@ public void PermissionsPage_UsesSharedVoiceSettingsCard_InsteadOfProviderControl
     [Fact]
     public void PermissionsPage_ShowsSharedVoiceCard_WhenEitherSpeechCapabilityIsEnabled_AndSetupTextOnlyWhenNeeded()
     {
-        var source = File.ReadAllText(GetCapabilitiesCodeBehindPath());
+        var pageSource = File.ReadAllText(GetCapabilitiesCodeBehindPath());
+        var viewModelSource = File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Presentation",
+            "PermissionsPageViewModel.cs"));
+        var appSource = File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "App.xaml.cs"));
         var readiness = File.ReadAllText(Path.Combine(
             TestRepositoryPaths.GetRepositoryRoot(),
             "src",
@@ -123,23 +134,25 @@ public void PermissionsPage_ShowsSharedVoiceCard_WhenEitherSpeechCapabilityIsEna
             "Services",
             "SpeechSetupReadiness.cs"));
 
-        Assert.Contains("settings?.NodeSttEnabled == true || settings?.NodeTtsEnabled == true", source);
-        Assert.Contains("VoiceSettingsCard.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed;", source);
-        Assert.Contains("GetVoiceSetupRequirement(settings)", source);
-        Assert.Contains("VoiceSetupRequirement.SpeechModel", source);
-        Assert.Contains("VoiceSetupRequirement.VoiceSetup", source);
-        Assert.Contains("VoiceSetupRequirement.SpeechModelAndVoiceSetup", source);
-        Assert.Contains("var needsSpeechModel = settings.NodeSttEnabled && !IsConfiguredWhisperModelDownloaded(settings)", source);
-        Assert.Contains("var needsVoiceSetup = settings.NodeTtsEnabled && SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired(settings)", source);
-        Assert.Contains("PermissionsPage_VoiceSettingsHelp_SpeechModel", source);
-        Assert.Contains("PermissionsPage_VoiceSettingsHelp_VoiceSetup", source);
-        Assert.Contains("PermissionsPage_VoiceSettingsHelp_Both", source);
+        Assert.Contains("_nodeSttEnabled || _nodeTtsEnabled", viewModelSource);
+        Assert.Contains("VoiceSettingsCard.Visibility = _viewModel.VoiceSettingsVisible ? Visibility.Visible : Visibility.Collapsed;", pageSource);
+        Assert.Contains("VoiceSettingsHelpPanel.Visibility = _viewModel.VoiceSetupRequirement != PermissionsVoiceSetupRequirement.None", pageSource);
+        Assert.Contains("PermissionsVoiceSetupRequirement.SpeechModel", viewModelSource);
+        Assert.Contains("PermissionsVoiceSetupRequirement.VoiceSetup", viewModelSource);
+        Assert.Contains("PermissionsVoiceSetupRequirement.SpeechModelAndVoiceSetup", viewModelSource);
+        Assert.Contains("SpeechSetupReadiness.IsConfiguredSttModelSetupRequired(_settings)", appSource);
+        Assert.DoesNotContain("VoiceService?.IsModelDownloaded", appSource);
+        Assert.Contains("SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired(_settings)", appSource);
+        Assert.Contains("var needsVoiceSetup = _settings?.NodeTtsEnabled == true", appSource);
+        Assert.Contains("PermissionsPage_VoiceSettingsHelp_SpeechModel", viewModelSource);
+        Assert.Contains("PermissionsPage_VoiceSettingsHelp_VoiceSetup", viewModelSource);
+        Assert.Contains("PermissionsPage_VoiceSettingsHelp_Both", viewModelSource);
         Assert.Contains("TtsCapability.WindowsProvider", readiness);
         Assert.Contains("TtsCapability.PiperProvider", readiness);
         Assert.Contains("TtsCapability.ElevenLabsProvider", readiness);
-        Assert.DoesNotContain("EnsureWhisperModelDownloaded", source);
-        Assert.DoesNotContain("UpdateSttCard", source);
-        Assert.DoesNotContain("UpdateTtsCard", source);
+        Assert.DoesNotContain("EnsureWhisperModelDownloaded", pageSource);
+        Assert.DoesNotContain("UpdateSttCard", pageSource);
+        Assert.DoesNotContain("UpdateTtsCard", pageSource);
     }
 
     [Fact]
diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
index 46793a2e7..2aa91faa4 100644
--- a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
+++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
@@ -215,6 +215,40 @@ public void CountMcpServed_OptInCapabilities_IncrementCount()
         Assert.Equal(baseline + 2, NodeCapabilityGating.CountMcpServedCapabilities(s));
     }
 
+    [Fact]
+    public void McpOnlyRestart_CameraDisabledWhileStoppedUsesFreshCompleteSet()
+    {
+        var s = NewSettings();
+        Assert.True(NodeCapabilityGating.ShouldRegisterCamera(s));
+        Assert.Equal(6, NodeCapabilityGating.CountMcpServedCapabilities(s));
+
+        s.NodeCameraEnabled = false;
+        var plan = McpRuntimeStatePolicy.PlanCapabilityEnable(
+            hasGatewayClient: false,
+            hasCapabilities: true);
+
+        Assert.Equal(McpCapabilityEnablePlan.RebuildFromCurrentSettings, plan);
+        Assert.False(NodeCapabilityGating.ShouldRegisterCamera(s));
+        Assert.Equal(5, NodeCapabilityGating.CountMcpServedCapabilities(s));
+    }
+
+    [Fact]
+    public void McpOnlyRestart_CameraRestoredWhileStoppedUsesFreshCompleteSet()
+    {
+        var s = NewSettings();
+        s.NodeCameraEnabled = false;
+        Assert.Equal(5, NodeCapabilityGating.CountMcpServedCapabilities(s));
+
+        s.NodeCameraEnabled = true;
+        var plan = McpRuntimeStatePolicy.PlanCapabilityEnable(
+            hasGatewayClient: false,
+            hasCapabilities: true);
+
+        Assert.Equal(McpCapabilityEnablePlan.RebuildFromCurrentSettings, plan);
+        Assert.True(NodeCapabilityGating.ShouldRegisterCamera(s));
+        Assert.Equal(6, NodeCapabilityGating.CountMcpServedCapabilities(s));
+    }
+
     // ── GetLocalNodeCapabilities ──────────────────────────────────────────────
 
     [Fact]
diff --git a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs
index 6de4ad5c8..2f8344d2d 100644
--- a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs
+++ b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs
@@ -117,40 +117,41 @@ public void App_GatewayNodeConnection_GatedOnNodeModeOnly_NotMcp()
     public void PermissionsPage_PresentsMcpOnlyNodeStatus()
     {
         var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+        var viewModel = ReadSource("src", "OpenClaw.Tray.WinUI", "Presentation", "PermissionsPageViewModel.cs");
+        var app = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs");
 
-        Assert.Contains("EnableMcpServer", page);
+        Assert.Contains("_viewModel.McpEnabled = McpToggle.IsOn;", page);
         Assert.Contains("PermissionsPage_NodeStatus_McpOnly", page);
         Assert.Contains("PermissionsPage_NodeStatus_McpOnlyDetailsFormat", page);
-        Assert.Contains("NodeService.McpServerUrl", page);
-        Assert.Contains("CountMcpServedCapabilities", page);
+        Assert.Contains("\"PermissionsPage_NodeStatus_McpOnly\"", viewModel);
+        Assert.Contains("NodeService.McpServerUrl", app);
+        Assert.Contains("CountMcpServedCapabilities", app);
         Assert.Contains("PermissionsPage_NodeStatus_McpError", page);
-        Assert.Contains("ActiveNodeService", page);
-        Assert.Contains("mcpEnabled && !string.IsNullOrEmpty(mcpStartupError)", page);
+        Assert.Contains("PermissionsNodeStatusKind.McpError", viewModel);
         Assert.Contains("McpStatusText.Text =", page);
     }
 
     [Fact]
     public void PermissionsPage_DrivesNodeStatusFromRoleState_AndSubscribesToChanges()
     {
-        var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+        var viewModel = ReadSource("src", "OpenClaw.Tray.WinUI", "Presentation", "PermissionsPageViewModel.cs");
+        var app = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs");
 
-        Assert.Contains("RoleConnectionState", page);
-        Assert.Contains("CurrentSnapshot", page);
-        Assert.Contains("PermissionsPage_NodeStatus_Starting", page);
-        Assert.Contains("StateChanged", page);
-        Assert.Contains("OnConnectionStateChanged", page);
+        Assert.Contains("RoleConnectionState", viewModel);
+        Assert.Contains("PermissionsPage_NodeStatus_Starting", viewModel);
+        Assert.Contains("event EventHandler? IPermissionsPageRuntimeHost.Changed", app);
+        Assert.Contains("PermissionsRuntimeChanged?.Invoke(this, EventArgs.Empty);", app);
+        Assert.Contains("GatewayConnectionSnapshot IPermissionsPageRuntimeHost.ConnectionSnapshot", app);
     }
 
     [Fact]
     public void PermissionsPage_CapabilityToggles_StayActionableInMcpOnly()
     {
-        var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
+        var viewModel = ReadSource("src", "OpenClaw.Tray.WinUI", "Presentation", "PermissionsPageViewModel.cs");
 
-        Assert.Contains(
-            "var canServe = (s?.EnableNodeMode ?? false) || (s?.EnableMcpServer ?? false);",
-            page);
-        Assert.Contains("BrowserProxyToggleIndex", page);
-        Assert.Contains("!isBrowserProxyToggle || s?.EnableNodeMode == true", page);
+        Assert.Contains("var featuresEnabled = _nodeModeEnabled || _mcpEnabled;", viewModel);
+        Assert.Contains("PermissionsCapabilityKey.BrowserProxy, _nodeBrowserProxyEnabled, featuresEnabled && _nodeModeEnabled", viewModel);
+        Assert.Contains("new PermissionsCapabilityState(PermissionsCapabilityKey.SystemRun, _nodeSystemRunEnabled, featuresEnabled)", viewModel);
     }
 
     [Fact]
@@ -159,7 +160,7 @@ public void PermissionsPage_McpToggleRefreshesNodeStatus()
         var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs");
 
         var toggle = ExtractMethodBody(page, "OnMcpToggled");
-        Assert.Contains("UpdateNodeStatus()", toggle);
+        Assert.Contains("_viewModel.McpEnabled = McpToggle.IsOn;", toggle);
     }
 
     [Fact]
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 2cd5e1088..b7b1bbf65 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -101,6 +101,7 @@
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\ConnectionDiagnosticsProjection.cs" Link="Services\ConnectionDiagnosticsProjection.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\McpRuntimeStatePolicy.cs" Link="Services\McpRuntimeStatePolicy.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\UsageCostApplicationPolicy.cs" Link="Services\UsageCostApplicationPolicy.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\SpeechSetupReadiness.cs" Link="Services\SpeechSetupReadiness.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\CanvasGatewayUrlRewriter.cs" Link="Helpers\CanvasGatewayUrlRewriter.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\DiagnosticsGate.cs" Link="Helpers\DiagnosticsGate.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\TrayTooltipFormatter.cs" Link="Helpers\TrayTooltipFormatter.cs" />
@@ -109,6 +110,7 @@
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\StatusBadgeIconFactory.cs" Link="Helpers\StatusBadgeIconFactory.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\Win32FilePickerHelper.cs" Link="Helpers\Win32FilePickerHelper.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\AppIdentity.cs" Link="AppIdentity.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\AppLogger.cs" Link="AppLogger.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\TrayStateSnapshot.cs" Link="Services\TrayStateSnapshot.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\TrayTooltipBuilder.cs" Link="Services\TrayTooltipBuilder.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\TrayMenuSnapshot.cs" Link="Services\TrayMenuSnapshot.cs" />
@@ -148,6 +150,8 @@
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\NavigationScopeManager.cs" Link="Presentation\NavigationScopeManager.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\AppNavigationService.cs" Link="Presentation\AppNavigationService.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\SettingsPageViewModel.cs" Link="Presentation\SettingsPageViewModel.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\PermissionsPagePresentationTypes.cs" Link="Presentation\PermissionsPagePresentationTypes.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\PermissionsPageRuntimeSource.cs" Link="Presentation\PermissionsPageRuntimeSource.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\PermissionsPageViewModel.cs" Link="Presentation\PermissionsPageViewModel.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\ISettingsStore.cs" Link="Presentation\ISettingsStore.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Presentation\SettingsStore.cs" Link="Presentation\SettingsStore.cs" />
diff --git a/tests/OpenClaw.Tray.Tests/PermissionSettingsWriterRoutingTests.cs b/tests/OpenClaw.Tray.Tests/PermissionSettingsWriterRoutingTests.cs
new file mode 100644
index 000000000..062d80610
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/PermissionSettingsWriterRoutingTests.cs
@@ -0,0 +1,150 @@
+namespace OpenClaw.Tray.Tests;
+
+public sealed class PermissionSettingsWriterRoutingTests
+{
+    [Fact]
+    public void HubCommandPalette_PermissionToggles_UseSettingsStore_AndRaiseSettingsSaved()
+    {
+        var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs");
+        var buildCommandList = ExtractMethodBodyBySignature(source, "internal List<CommandItem> BuildCommandList()");
+        var toggleHelper = ExtractMethodBodyBySignature(source, "private void ToggleCommandPalettePermission(");
+
+        Assert.Contains("ToggleCommandPalettePermission(", buildCommandList);
+        Assert.DoesNotContain("settings.Save(); RaiseSettingsSaved();", buildCommandList);
+
+        Assert.Contains("CurrentApp.SettingsStore", toggleHelper);
+        Assert.Contains("_commandPaletteSettingsOrigin ??= store.CreateOrigin();", toggleHelper);
+        Assert.Contains("store.Update(_commandPaletteSettingsOrigin", toggleHelper);
+        Assert.Contains("RaiseSettingsSaved();", toggleHelper);
+    }
+
+    [Fact]
+    public void TrayPermissionsFlyout_PreservesCurrentLabelsOrder_AndRoutesThroughStoreCallback()
+    {
+        var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Services", "TrayMenuStateBuilder.cs");
+        var buildPermissionsFlyout = ExtractMethodBodyBySignature(source, "private List<TrayMenuFlyoutItem> BuildPermissionsFlyoutItems(SettingsManager settings)");
+        var addPermToggle = ExtractMethodBodyBySignature(source, "private void AddPermToggle(");
+
+        AssertInOrder(
+            buildPermissionsFlyout,
+            "\"Windows node\"",
+            "\"System tools\"",
+            "\"Browser control\"",
+            "\"Camera\"",
+            "\"Canvas\"",
+            "\"Screen capture\"",
+            "\"Location\"",
+            "\"Voice (TTS)\"",
+            "\"Speech-to-text (STT)\"");
+
+        Assert.Contains("_callbacks.UpdatePermissionAndReconnect(", addPermToggle);
+        Assert.DoesNotContain("_callbacks.SaveAndReconnect();", addPermToggle);
+        Assert.DoesNotContain("set(!get());", addPermToggle);
+    }
+
+    [Fact]
+    public void AppTrayCallback_PersistsPermissionWrites_BeforeSingleReconnect()
+    {
+        var source = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs");
+        var helper = ExtractMethodBodyBySignature(source, "private bool TryPersistPermissionSetting(");
+        var trayPopup = ExtractMethodBodyBySignature(source, "private void BuildTrayMenuPopup(TrayMenuWindow menu)");
+
+        Assert.Contains("store.Update(GetOrCreateSettingsWriteOrigin", helper);
+        Assert.Contains("_settings.Save();", helper);
+
+        Assert.Contains("UpdatePermissionAndReconnect:", trayPopup);
+        Assert.DoesNotContain("_settings?.Save(); ReconnectWithSyncedBrowserProxyForward();", trayPopup);
+        Assert.Contains("ReconnectWithSyncedBrowserProxyForward();", trayPopup);
+    }
+
+    [Fact]
+    public void ConnectionPage_NodeModeToggle_UsesStoreBeforeMaskAndNotify()
+    {
+        var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs");
+        var toggleBody = ExtractMethodBodyBySignature(source, "private void OnNodeModeToggled(object sender, RoutedEventArgs e)");
+        var persistBody = ExtractMethodBodyBySignature(source, "private bool TryPersistNodeModeSetting(bool enabled)");
+
+        Assert.DoesNotContain("settings.Save();", toggleBody);
+        Assert.True(
+            toggleBody.IndexOf("TryPersistNodeModeSetting(NodeModeToggle.IsOn)", StringComparison.Ordinal) <
+            toggleBody.IndexOf("BeginReconnectMask();", StringComparison.Ordinal));
+        Assert.True(
+            toggleBody.IndexOf("BeginReconnectMask();", StringComparison.Ordinal) <
+            toggleBody.IndexOf("NotifySettingsSaved();", StringComparison.Ordinal));
+
+        Assert.Contains("CurrentApp.SettingsStore", persistBody);
+        Assert.Contains("_nodeModeSettingsOrigin ??= store.CreateOrigin();", persistBody);
+        Assert.Contains("store.Update(_nodeModeSettingsOrigin, edit => edit.EnableNodeMode = enabled);", persistBody);
+    }
+
+    [Fact]
+    public void AppCapability_SettingsSet_UsesStoreForPermissionFlags_ButKeepsDirectSaveForOtherSafeSettings()
+    {
+        var source = ReadSource("src", "OpenClaw.Tray.WinUI", "App.CapabilityHandlers.cs");
+
+        Assert.Contains("TryGetStoreManagedPermissionValue", source);
+        Assert.Contains("ref _appCapabilityPermissionWriteOrigin", source);
+        Assert.Contains("ApplyStoreManagedPermissionSetting(edit, name, permissionValue)", source);
+        Assert.Contains("ApplyStoreManagedPermissionSetting(settings, name, permissionValue)", source);
+        Assert.Contains("prop.SetValue(_settings, converted);", source);
+        Assert.Contains("_settings.Save();", source);
+        Assert.Contains("OnSettingsSaved(this, EventArgs.Empty);", source);
+
+        foreach (var settingName in new[]
+        {
+            "EnableNodeMode",
+            "EnableMcpServer",
+            "NodeCanvasEnabled",
+            "NodeScreenEnabled",
+            "NodeCameraEnabled",
+            "NodeLocationEnabled",
+            "NodeBrowserProxyEnabled",
+            "NodeTtsEnabled",
+        })
+        {
+            Assert.Contains($"nameof(SettingsManager.{settingName})", source);
+        }
+    }
+
+    private static string ExtractMethodBodyBySignature(string source, string signature)
+    {
+        var signatureIndex = source.IndexOf(signature, StringComparison.Ordinal);
+        Assert.True(signatureIndex >= 0, $"Could not find signature {signature}.");
+
+        var bodyStart = source.IndexOf('{', signatureIndex);
+        Assert.True(bodyStart >= 0, $"Could not find body for {signature}.");
+
+        var depth = 0;
+        for (var index = bodyStart; index < source.Length; index++)
+        {
+            if (source[index] == '{')
+            {
+                depth++;
+            }
+            else if (source[index] == '}')
+            {
+                depth--;
+                if (depth == 0)
+                {
+                    return source[bodyStart..(index + 1)];
+                }
+            }
+        }
+
+        throw new InvalidOperationException($"Could not extract method body for {signature}.");
+    }
+
+    private static string ReadSource(params string[] relativePathParts) =>
+        File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(relativePathParts).ToArray()));
+
+    private static void AssertInOrder(string source, params string[] snippets)
+    {
+        var cursor = 0;
+        foreach (var snippet in snippets)
+        {
+            var index = source.IndexOf(snippet, cursor, StringComparison.Ordinal);
+            Assert.True(index >= 0, $"Expected to find '{snippet}' after position {cursor}.");
+            cursor = index + snippet.Length;
+        }
+    }
+}
diff --git a/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs
new file mode 100644
index 000000000..e82447361
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs
@@ -0,0 +1,150 @@
+using System.Xml.Linq;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class PermissionsPageContractTests
+{
+    private static readonly XNamespace XNs = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+    [Fact]
+    public void PermissionsPage_Xaml_PreservesCurrentSectionOrderAndMarkers()
+    {
+        var xaml = ReadPermissionsXaml();
+
+        AssertInOrder(
+            xaml,
+            "PermissionsPage_Permissions",
+            "NodeStatusCard",
+            "PermissionsPage_Capabilities",
+            "PermissionsVoiceSettingsCard",
+            "PermissionsPage_Integrations",
+            "PermissionsPage_TextBlock_14",
+            "PermissionsPage_TextBlock_69",
+            "PermissionsPage_WindowsPrivacy");
+        Assert.Contains("AutomationProperties.AutomationId=\"PermissionsPageMarker\"", xaml);
+        Assert.Contains("AutomationProperties.AutomationId=\"CapabilitiesNodeModeToggle\"", xaml);
+    }
+
+    [Fact]
+    public void PermissionsPage_ExecPolicyCombos_PreserveDenyAllowAskChoices()
+    {
+        var doc = XDocument.Load(GetPermissionsXamlPath());
+
+        var combos = doc.Descendants()
+            .Where(element => element.Name.LocalName == "ComboBox")
+            .ToDictionary(
+                element => element.Attribute("Name")?.Value ?? element.Attribute(XNs + "Name")?.Value ?? string.Empty,
+                element => element.Elements().Where(child => child.Name.LocalName == "ComboBoxItem")
+                    .Select(child => child.Attribute("Tag")?.Value)
+                    .Where(tag => !string.IsNullOrWhiteSpace(tag))
+                    .ToArray(),
+                StringComparer.Ordinal);
+
+        Assert.Equal(new[] { "deny", "allow", "prompt" }, combos["DefaultActionCombo"]);
+        Assert.Equal(new[] { "allow" }, combos["NewRuleAction"]);
+    }
+
+    [Fact]
+    public void App_MapsPermissionsPage_ToTransientViewModel()
+    {
+        var app = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs");
+
+        Assert.Contains("[typeof(Pages.PermissionsPage)] = typeof(PermissionsPageViewModel)", app);
+    }
+
+    [Fact]
+    public void HubWindow_NoLongerInitializesPermissionsPageDirectly()
+    {
+        var hub = ReadSource("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs");
+
+        Assert.DoesNotContain("case PermissionsPage permissions: permissions.Initialize();", hub);
+    }
+
+    [Fact]
+    public void PermissionsPage_CodeBehind_UsesViewModelBoundary_AndAvoidsDirectWriters()
+    {
+        var source = ReadPermissionsCodeBehind();
+
+        Assert.Contains("DataContextChanged += OnDataContextChanged;", source);
+        Assert.DoesNotContain("CurrentApp.Settings.Save()", source);
+        Assert.DoesNotContain("CurrentApp.Settings.EnableNodeMode", source);
+        Assert.DoesNotContain("CurrentApp.Settings.EnableMcpServer", source);
+        Assert.DoesNotContain("CurrentApp.ConnectionManager", source);
+        Assert.DoesNotContain("Saved += OnSettingsSaved", source);
+        Assert.DoesNotContain("StateChanged += OnConnectionStateChanged", source);
+        Assert.DoesNotContain("ExecApprovalsStore.GetSnapshotAsync", source);
+        Assert.DoesNotContain("ExecApprovalsStore.ReplaceAsync", source);
+    }
+
+    [Fact]
+    public void PermissionsPage_RemoveRuleBindsStableRuleIdentityAndPreservesAutomationIndex()
+    {
+        var xaml = ReadPermissionsXaml();
+        var source = ReadPermissionsCodeBehind();
+
+        Assert.Contains("Tag=\"{Binding Rule}\"", xaml);
+        Assert.DoesNotContain("Tag=\"{Binding Index}\"", xaml);
+        Assert.Contains("Rule = rule", source);
+        Assert.Contains("button.Tag is PermissionsExecApprovalRule rule", source);
+        Assert.Contains("RemoveExecApprovalRuleAsync(rule)", source);
+        Assert.Contains("RemoveExecPolicyRuleButton_{index}", source);
+        Assert.DoesNotContain("RemoveExecApprovalRuleAtAsync", source);
+    }
+
+    [Fact]
+    public void PermissionsPageViewModel_StaysWinUiAndAppFree()
+    {
+        var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Presentation", "PermissionsPageViewModel.cs");
+
+        foreach (var banned in new[]
+        {
+            "Microsoft.UI",
+            "Application.Current",
+            "SettingsManager",
+            "CurrentApp",
+            " File.",
+            " Directory.",
+            " Path.",
+            "Process.",
+            "Clipboard",
+        })
+        {
+            Assert.DoesNotContain(banned, source);
+        }
+    }
+
+    [Fact]
+    public void PermissionsPage_UsesDataContextChangedLikeSettingsPage()
+    {
+        var permissions = ReadPermissionsCodeBehind();
+        var settings = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml.cs");
+
+        Assert.Contains("DataContextChanged += OnDataContextChanged;", permissions);
+        Assert.Contains("DataContextChanged += OnDataContextChanged;", settings);
+    }
+
+    private static string GetPermissionsXamlPath() =>
+        Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml");
+
+    private static string ReadPermissionsXaml() => File.ReadAllText(GetPermissionsXamlPath());
+
+    private static string ReadPermissionsCodeBehind() => ReadSource(
+        "src",
+        "OpenClaw.Tray.WinUI",
+        "Pages",
+        "PermissionsPage.xaml.cs");
+
+    private static string ReadSource(params string[] relativePathParts) =>
+        File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(relativePathParts).ToArray()));
+
+    private static void AssertInOrder(string source, params string[] expectedSnippets)
+    {
+        var cursor = 0;
+        foreach (var snippet in expectedSnippets)
+        {
+            var index = source.IndexOf(snippet, cursor, StringComparison.Ordinal);
+            Assert.True(index >= 0, $"Expected to find '{snippet}' after position {cursor}.");
+            cursor = index + snippet.Length;
+        }
+    }
+}
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs
index 268733332..5794a9f64 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/AppServiceRegistrationTests.cs
@@ -1,4 +1,7 @@
+using System.IO;
 using Microsoft.Extensions.DependencyInjection;
+using OpenClaw.Shared;
+using OpenClaw.Shared.ExecApprovals;
 using OpenClawTray.Presentation;
 using OpenClawTray.Services;
 
@@ -6,9 +9,9 @@ namespace OpenClaw.Tray.Tests.Presentation;
 
 /// <summary>
 /// Behavioral guard for the composition root. Locks build-time validation, singleton
-/// identity of App-owned instances, transient page-view-model lifetime, and — most
-/// importantly — that the container never disposes App-owned pre-built instances
-/// (no double-dispose) while it does dispose what it created.
+/// identity of App-owned instances, transient page-view-model lifetime, and that the
+/// container never disposes App-owned pre-built instances while it does dispose what it
+/// created.
 /// </summary>
 public sealed class AppServiceRegistrationTests
 {
@@ -16,15 +19,24 @@ private static ServiceProvider BuildProvider(
         out RecordingUiDispatcher dispatcher,
         out FakeAppCommands commands,
         out SettingsManager settings,
+        out ExecApprovalsStore execApprovalsStore,
+        out FakePermissionsPageRuntimeHost runtimeHost,
         out TempDir temp)
     {
         temp = new TempDir();
         dispatcher = new RecordingUiDispatcher();
         commands = new FakeAppCommands();
         settings = new SettingsManager(temp.Path);
+        execApprovalsStore = new ExecApprovalsStore(temp.Path, NullLogger.Instance);
+        runtimeHost = new FakePermissionsPageRuntimeHost();
 
         var services = new ServiceCollection();
-        services.AddOpenClawTrayCore(new AppServiceContext(dispatcher, commands, settings));
+        services.AddOpenClawTrayCore(new AppServiceContext(
+            dispatcher,
+            commands,
+            settings,
+            execApprovalsStore,
+            runtimeHost));
         return services.BuildServiceProvider(new ServiceProviderOptions
         {
             ValidateScopes = true,
@@ -35,10 +47,7 @@ private static ServiceProvider BuildProvider(
     [Fact]
     public void Build_ValidatesOnBuild_WithoutThrowing()
     {
-        // Reaching this point means BuildServiceProvider(ValidateOnBuild: true) did not
-        // throw, i.e. every registration (including transient page view models) is
-        // constructable from the registered dependencies.
-        var provider = BuildProvider(out _, out _, out _, out var temp);
+        var provider = BuildProvider(out _, out _, out _, out _, out _, out var temp);
         using (provider)
         using (temp)
         {
@@ -49,49 +58,51 @@ public void Build_ValidatesOnBuild_WithoutThrowing()
     [Fact]
     public void AppOwnedSingletons_ResolveToTheProvidedInstances()
     {
-        var provider = BuildProvider(out var dispatcher, out var commands, out var settings, out var temp);
+        var provider = BuildProvider(out var dispatcher, out var commands, out var settings, out var execApprovalsStore, out var runtimeHost, out var temp);
         using (provider)
         using (temp)
         {
             Assert.Same(dispatcher, provider.GetRequiredService<IUiDispatcher>());
             Assert.Same(commands, provider.GetRequiredService<IAppCommands>());
             Assert.Same(settings, provider.GetRequiredService<SettingsManager>());
+            Assert.Same(execApprovalsStore, provider.GetRequiredService<IExecApprovalsPresentationStore>());
+            Assert.Same(runtimeHost, provider.GetRequiredService<IPermissionsPageRuntimeHost>());
+            Assert.Same(provider.GetRequiredService<ISettingsStore>(), provider.GetRequiredService<ISettingsStore>());
+            Assert.Same(provider.GetRequiredService<IPermissionsPageRuntimeSource>(), provider.GetRequiredService<IPermissionsPageRuntimeSource>());
         }
     }
 
     [Fact]
     public void PageViewModels_AreTransient_AndReceiveInjectedServices()
     {
-        var provider = BuildProvider(out var dispatcher, out var commands, out var settings, out var temp);
+        var provider = BuildProvider(out var dispatcher, out var commands, out _, out var execApprovalsStore, out _, out var temp);
         using (provider)
         using (temp)
+        using (var scope = provider.CreateScope())
         {
-            using var scope = provider.CreateScope();
-            var first = scope.ServiceProvider.GetRequiredService<SettingsPageViewModel>();
-            var second = scope.ServiceProvider.GetRequiredService<SettingsPageViewModel>();
+            var first = scope.ServiceProvider.GetRequiredService<PermissionsPageViewModel>();
+            var second = scope.ServiceProvider.GetRequiredService<PermissionsPageViewModel>();
 
             Assert.NotSame(first, second);
-
-            // The placeholder permissions view model still exposes its injected services, so use it
-            // to prove the transient page view models receive the registered singleton instances.
-            var permissions = scope.ServiceProvider.GetRequiredService<PermissionsPageViewModel>();
-            Assert.Same(dispatcher, permissions.Dispatcher);
-            Assert.Same(commands, permissions.AppCommands);
-            Assert.Same(settings, permissions.Settings);
+            Assert.Same(dispatcher, first.Dispatcher);
+            Assert.Same(commands, first.AppCommands);
+            Assert.Same(scope.ServiceProvider.GetRequiredService<ISettingsStore>(), first.SettingsStore);
+            Assert.Same(execApprovalsStore, first.ExecApprovalsStore);
+            Assert.Same(scope.ServiceProvider.GetRequiredService<IPermissionsPageRuntimeSource>(), first.RuntimeSource);
         }
     }
 
     [Fact]
     public void PageViewModel_ResolvedFromScope_IsDisposedWithScope()
     {
-        var provider = BuildProvider(out _, out _, out _, out var temp);
+        var provider = BuildProvider(out _, out _, out _, out _, out _, out var temp);
         using (provider)
         using (temp)
         {
-            SettingsPageViewModel vm;
+            PermissionsPageViewModel vm;
             using (var scope = provider.CreateScope())
             {
-                vm = scope.ServiceProvider.GetRequiredService<SettingsPageViewModel>();
+                vm = scope.ServiceProvider.GetRequiredService<PermissionsPageViewModel>();
                 Assert.False(vm.IsDisposed);
             }
 
@@ -102,21 +113,32 @@ public void PageViewModel_ResolvedFromScope_IsDisposedWithScope()
     [Fact]
     public void Dispose_DoesNotDisposeAppOwnedInstanceSingletons()
     {
-        var provider = BuildProvider(out var dispatcher, out var commands, out _, out var temp);
+        var provider = BuildProvider(out var dispatcher, out var commands, out _, out _, out _, out var temp);
         using (temp)
         {
             var manager = provider.GetRequiredService<NavigationScopeManager>();
 
             provider.Dispose();
 
-            // App-owned pre-built instances: the container must NOT dispose them, so App
-            // remains their sole owner and there is no double-dispose. Both the dispatcher
-            // and IAppCommands are IDisposable instance registrations, so asserting both
-            // proves the container never disposes an instance it did not create.
             Assert.False(dispatcher.Disposed);
             Assert.False(commands.Disposed);
-            // Container-created singleton: the container DOES dispose it.
             Assert.True(manager.IsDisposed);
         }
     }
+
+    [Fact]
+    public void ResolvingExecApprovalsStore_IsPure_AndSingleton()
+    {
+        var provider = BuildProvider(out _, out _, out _, out var execApprovalsStore, out _, out var temp);
+        using (provider)
+        using (temp)
+        {
+            var first = provider.GetRequiredService<IExecApprovalsPresentationStore>();
+            var second = provider.GetRequiredService<IExecApprovalsPresentationStore>();
+
+            Assert.Same(execApprovalsStore, first);
+            Assert.Same(first, second);
+            Assert.False(File.Exists(ExecApprovalsStore.ResolveFilePath(temp.Path)));
+        }
+    }
 }
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/NavigationIntegrationTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/NavigationIntegrationTests.cs
index e4378bf9e..a0e62cbf2 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/NavigationIntegrationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/NavigationIntegrationTests.cs
@@ -1,4 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
+using OpenClaw.Shared;
+using OpenClaw.Shared.ExecApprovals;
 using OpenClawTray.Presentation;
 using OpenClawTray.Services;
 
@@ -6,9 +8,7 @@ namespace OpenClaw.Tray.Tests.Presentation;
 
 /// <summary>
 /// Behavioral integration proof that composes the real composition root, the navigation
-/// scope manager, and the registered transient page view models — driving the full
-/// lifecycle (open → navigate A/B → close → reopen → shutdown) and asserting the
-/// ownership invariants end to end without hosting HubWindow.
+/// scope manager, and the registered transient page view models.
 /// </summary>
 public sealed class NavigationIntegrationTests
 {
@@ -16,8 +16,13 @@ private static ServiceProvider BuildRealContainer(out TempDir temp)
     {
         temp = new TempDir();
         var services = new ServiceCollection();
+        var execApprovalsStore = new ExecApprovalsStore(temp.Path, NullLogger.Instance);
         services.AddOpenClawTrayCore(new AppServiceContext(
-            new RecordingUiDispatcher(), new FakeAppCommands(), new SettingsManager(temp.Path)));
+            new RecordingUiDispatcher(),
+            new FakeAppCommands(),
+            new SettingsManager(temp.Path),
+            execApprovalsStore,
+            new FakePermissionsPageRuntimeHost()));
         return services.BuildServiceProvider(new ServiceProviderOptions
         {
             ValidateScopes = true,
@@ -33,30 +38,24 @@ public void FullLifecycle_OpenNavigateCloseReopenShutdown_HonorsOwnership()
         {
             var manager = provider.GetRequiredService<NavigationScopeManager>();
 
-            // open + navigate A (Settings)
             var a = Assert.IsType<SettingsPageViewModel>(manager.Navigate(typeof(SettingsPageViewModel), "a"));
             Assert.True(a.IsActive);
 
-            // navigate B (Permissions) — A deactivated + disposed before B is active
             var b = Assert.IsType<PermissionsPageViewModel>(manager.Navigate(typeof(PermissionsPageViewModel), "b"));
             Assert.False(a.IsActive);
             Assert.True(a.IsDisposed);
             Assert.True(b.IsActive);
             Assert.False(b.IsDisposed);
 
-            // close (Hub close → Reset) — B deactivated + disposed, nothing current
             manager.Reset();
             Assert.False(b.IsActive);
             Assert.True(b.IsDisposed);
             Assert.Null(manager.CurrentViewModel);
 
-            // reopen — fresh A instance, not the disposed one
             var a2 = Assert.IsType<SettingsPageViewModel>(manager.Navigate(typeof(SettingsPageViewModel), "a2"));
             Assert.NotSame(a, a2);
             Assert.True(a2.IsActive);
 
-            // shutdown — dispose the provider; the container disposes the manager it created,
-            // which tears down the current scope/view model.
             provider.Dispose();
             Assert.True(manager.IsDisposed);
             Assert.True(a2.IsDisposed);
@@ -66,8 +65,6 @@ public void FullLifecycle_OpenNavigateCloseReopenShutdown_HonorsOwnership()
     [Fact]
     public void FrameHandlerSimulation_ContainsActivationException_AndDisposesScope()
     {
-        // Mirror HubWindow.OnContentFrameNavigated: the activator call is wrapped in a
-        // try/catch so an activation throw cannot escape the frame-navigated handler.
         var created = new List<ThrowingActivateViewModel>();
         var services = new ServiceCollection();
         services.AddTransient(_ =>
@@ -87,14 +84,12 @@ void FrameHandler(Type vmType)
             }
             catch
             {
-                // Contained — mirrors the HubWindow try/catch-log around the activation hook.
             }
         }
 
         var escaped = Record.Exception(() => FrameHandler(typeof(ThrowingActivateViewModel)));
 
         Assert.Null(escaped);
-        // The half-activated scope was disposed, not leaked.
         Assert.Null(manager.CurrentViewModel);
         Assert.True(Assert.Single(created).Disposed);
     }
@@ -110,8 +105,6 @@ public void AfterShutdown_LateNavigation_DoesNotResolveFromDisposedProvider()
 
             provider.Dispose();
 
-            // The manager is disposed with the provider, so a late navigation cannot
-            // create a new scope / resolve a view model from the disposed provider.
             Assert.True(manager.IsDisposed);
             Assert.Throws<ObjectDisposedException>(() => manager.Navigate(typeof(SettingsPageViewModel), null));
         }
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs
new file mode 100644
index 000000000..268a12bbf
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs
@@ -0,0 +1,1187 @@
+using System.ComponentModel;
+using System.Reflection;
+using System.Text.Json;
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+using OpenClaw.Shared.Audio;
+using OpenClaw.Shared.Capabilities;
+using OpenClaw.Shared.ExecApprovals;
+using OpenClawTray.Presentation;
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests.Presentation;
+
+public sealed class PermissionsPageViewModelTests
+{
+    [Fact]
+    public void RuntimeSource_DistinguishesAllowlistEmptyAndMalformedStates()
+    {
+        var host = new FakePermissionsPageRuntimeHost();
+        using var source = new PermissionsPageRuntimeSource(host);
+
+        Assert.Equal(PermissionsGatewayAllowlistState.NoConfig, source.Current.GatewayAllowlistState);
+
+        host.GatewayConfig = JsonDocument.Parse("""{"gateway":{"nodes":{"allowCommands":[]}}}""").RootElement.Clone();
+        Assert.Equal(PermissionsGatewayAllowlistState.NoCommands, source.Current.GatewayAllowlistState);
+
+        host.GatewayConfig = JsonDocument.Parse("""{"gateway":{"nodes":{"allowCommands":["system.run"]}}}""").RootElement.Clone();
+        Assert.Equal(PermissionsGatewayAllowlistState.Commands, source.Current.GatewayAllowlistState);
+        Assert.Equal(new[] { "system.run" }, source.Current.GatewayAllowCommands);
+
+        host.GatewayConfig = JsonDocument.Parse("""{"gateway":{"nodes":{"allowCommands":[42]}}}""").RootElement.Clone();
+        Assert.Equal(PermissionsGatewayAllowlistState.ParseFailed, source.Current.GatewayAllowlistState);
+        Assert.Empty(source.Current.GatewayAllowCommands);
+    }
+
+    [Fact]
+    public void Activate_ReadOnlyExecSnapshot_DoesNotCreateFile()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+
+        harness.ViewModel.Activate(null);
+
+        Assert.False(File.Exists(harness.ExecApprovalsPath));
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.Empty(harness.ViewModel.ExecApprovalRules);
+    }
+
+    [Fact]
+    public void SettingsWrites_SaveBeforeNotify_AndCapabilityFieldsMapCorrectly()
+    {
+        foreach (var (key, readSetting) in new (PermissionsCapabilityKey, Func<SettingsManager, bool>)[]
+        {
+            (PermissionsCapabilityKey.SystemRun, s => s.NodeSystemRunEnabled),
+            (PermissionsCapabilityKey.BrowserProxy, s => s.NodeBrowserProxyEnabled),
+            (PermissionsCapabilityKey.Camera, s => s.NodeCameraEnabled),
+            (PermissionsCapabilityKey.Canvas, s => s.NodeCanvasEnabled),
+            (PermissionsCapabilityKey.Screen, s => s.NodeScreenEnabled),
+            (PermissionsCapabilityKey.Location, s => s.NodeLocationEnabled),
+            (PermissionsCapabilityKey.TextToSpeech, s => s.NodeTtsEnabled),
+            (PermissionsCapabilityKey.SpeechToText, s => s.NodeSttEnabled),
+        })
+        {
+            using var harness = PermissionsHarness.CreateReal();
+            harness.SettingsStore.Update(harness.SettingsStore.CreateOrigin(), edit => SetCapability(edit, key, true));
+            harness.ViewModel.Activate(null);
+            var order = new List<string>();
+            harness.Commands.ClearOperationLog();
+            harness.Settings.Saved += (_, _) => order.Add("save");
+
+            harness.ViewModel.SetCapabilityEnabled(key, false);
+
+            order.AddRange(harness.Commands.OperationLog);
+            Assert.False(readSetting(harness.Settings));
+            Assert.Equal(new[] { "save", "notify" }, order);
+        }
+    }
+
+    [Fact]
+    public void CapabilityWrite_PublishesUpdatedProjectionWithoutReentrantSave()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.SettingsStore.Update(
+            harness.SettingsStore.CreateOrigin(),
+            edit => edit.NodeCameraEnabled = false);
+        harness.ViewModel.Activate(null);
+        harness.Commands.ClearOperationLog();
+
+        var saveCount = 0;
+        var publishedStates = new List<bool>();
+        harness.Settings.Saved += (_, _) => saveCount++;
+        harness.ViewModel.PropertyChanged += (_, e) =>
+        {
+            if (e.PropertyName != nameof(PermissionsPageViewModel.Capabilities))
+            {
+                return;
+            }
+
+            var publishedState = harness.ViewModel.Capabilities
+                .Single(capability => capability.Key == PermissionsCapabilityKey.Camera)
+                .IsOn;
+            publishedStates.Add(publishedState);
+            harness.ViewModel.SetCapabilityEnabled(PermissionsCapabilityKey.Camera, publishedState);
+        };
+
+        harness.ViewModel.SetCapabilityEnabled(PermissionsCapabilityKey.Camera, true);
+
+        Assert.True(harness.Settings.NodeCameraEnabled);
+        Assert.Equal(new[] { true }, publishedStates);
+        Assert.Equal(1, saveCount);
+        Assert.Equal(new[] { "notify" }, harness.Commands.OperationLog);
+    }
+
+    [Fact]
+    public void NodeModeAndMcp_WriteThroughStore_SaveBeforeNotify()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+
+        var order = new List<string>();
+        harness.Settings.Saved += (_, _) => order.Add("save");
+
+        harness.ViewModel.NodeModeEnabled = true;
+
+        Assert.True(harness.Settings.EnableNodeMode);
+        Assert.Equal(new[] { "save", "notify" }, order.Concat(harness.Commands.OperationLog).ToArray());
+
+        order.Clear();
+        harness.Commands.ClearOperationLog();
+
+        harness.ViewModel.McpEnabled = true;
+
+        Assert.True(harness.Settings.EnableMcpServer);
+        Assert.Equal(new[] { "save", "notify" }, order.Concat(harness.Commands.OperationLog).ToArray());
+    }
+
+    [Fact]
+    public void McpOnly_RuntimeProjectsFeatureAndTokenState()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.Settings.EnableMcpServer = true;
+        harness.Settings.EnableNodeMode = false;
+        harness.Settings.Save();
+        harness.RuntimeHost.ConnectionSnapshot = GatewayConnectionSnapshot.Idle;
+        harness.RuntimeHost.McpServedCapabilityCount = NodeCapabilityGating.CountMcpServedCapabilities(harness.Settings);
+
+        harness.ViewModel.Activate(null);
+
+        Assert.True(harness.ViewModel.AreFeaturesEnabled);
+        Assert.Equal(PermissionsNodeStatusKind.McpOnly, harness.ViewModel.NodeStatusKind);
+        Assert.Equal(PermissionsMcpTokenState.Pending, harness.ViewModel.McpTokenState);
+        Assert.False(harness.ViewModel.Capabilities.Single(c => c.Key == PermissionsCapabilityKey.BrowserProxy).IsInteractive);
+        Assert.Equal(NodeCapabilityGating.CountMcpServedCapabilities(harness.Settings), harness.ViewModel.McpServedCapabilityCount);
+    }
+
+    [Fact]
+    public void ConnectedNode_UsesGatewayCapabilitiesProjection()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.Settings.EnableNodeMode = true;
+        harness.RuntimeHost.ConnectionSnapshot = GatewayConnectionSnapshot.Idle with
+        {
+            OperatorState = RoleConnectionState.Connected,
+            NodeState = RoleConnectionState.Connected,
+        };
+        harness.RuntimeHost.LocalNodeDeviceId = "device-1";
+        harness.RuntimeHost.Nodes =
+        [
+            new GatewayNodeInfo
+            {
+                NodeId = "device-1",
+                Capabilities = ["system", "canvas"],
+            },
+        ];
+
+        harness.ViewModel.Activate(null);
+
+        Assert.Equal(PermissionsNodeStatusKind.Active, harness.ViewModel.NodeStatusKind);
+        Assert.Equal(2, harness.ViewModel.LocalNodeCapabilityCount);
+        Assert.Equal(new[] { "system", "canvas" }, harness.ViewModel.LocalNodeCapabilities);
+    }
+
+    [Fact]
+    public void VoiceSetupRequirement_ProjectsSpeechModelAndProviderState()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.Settings.NodeSttEnabled = true;
+        harness.Settings.NodeTtsEnabled = true;
+        harness.Settings.SttModelName = "missing-model";
+        harness.Settings.TtsProvider = TtsCapability.ElevenLabsProvider;
+        harness.Settings.TtsElevenLabsApiKey = "";
+        harness.Settings.TtsElevenLabsVoiceId = "";
+        harness.RuntimeHost.VoiceSetupRequirement = PermissionsVoiceSetupRequirement.SpeechModelAndVoiceSetup;
+
+        harness.ViewModel.Activate(null);
+
+        Assert.True(harness.ViewModel.VoiceSettingsVisible);
+        Assert.Equal(PermissionsVoiceSetupRequirement.SpeechModelAndVoiceSetup, harness.ViewModel.VoiceSetupRequirement);
+    }
+
+    [Fact]
+    public void ExternalSettingsSave_UpdatesOnce_AndOwnWritesAreIgnored()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+        var external = 0;
+        harness.ViewModel.ExternalChanged += (_, _) => external++;
+
+        harness.ViewModel.NodeModeEnabled = true;
+        Assert.Equal(0, external);
+
+        harness.Settings.EnableNodeMode = false;
+        harness.Settings.Save();
+
+        Assert.Equal(1, external);
+        Assert.False(harness.ViewModel.NodeModeEnabled);
+    }
+
+    [Fact]
+    public void DeactivateAndDispose_Unsubscribe_AndReopenGetsFreshState()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+        harness.ViewModel.NodeModeEnabled = true;
+
+        harness.ViewModel.Deactivate();
+        harness.Settings.EnableNodeMode = false;
+        harness.Settings.Save();
+
+        Assert.True(harness.ViewModel.NodeModeEnabled);
+
+        harness.ViewModel.Dispose();
+        Assert.True(harness.ViewModel.IsDisposed);
+
+        var reopened = new PermissionsPageViewModel(
+            harness.SettingsStore,
+            harness.ExecApprovalsStore,
+            harness.Commands,
+            harness.Dispatcher,
+            harness.RuntimeSource);
+        reopened.Activate(null);
+        Assert.False(reopened.NodeModeEnabled);
+        reopened.Dispose();
+    }
+
+    [Fact]
+    public async Task BackgroundSettingsRuntimeAndApprovalsEvents_MarshalThroughDispatcher()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore();
+        harness.Dispatcher.HasThreadAccess = false;
+        harness.Dispatcher.RunEnqueuedImmediately = false;
+        harness.ViewModel.Activate(null);
+
+        await Task.Run(() =>
+        {
+            harness.Settings.EnableNodeMode = true;
+            harness.Settings.Save();
+        });
+
+        Assert.False(harness.ViewModel.NodeModeEnabled);
+        Assert.True(harness.Dispatcher.EnqueuedCount >= 1);
+        harness.Dispatcher.FlushPending();
+        harness.Dispatcher.FlushPending();
+        Assert.True(harness.ViewModel.NodeModeEnabled);
+
+        harness.Dispatcher.HasThreadAccess = false;
+        harness.Dispatcher.RunEnqueuedImmediately = false;
+        await Task.Run(() =>
+        {
+            harness.RuntimeHost.ConnectionSnapshot = GatewayConnectionSnapshot.Idle with { NodeState = RoleConnectionState.Connecting };
+            harness.RuntimeHost.RaiseChanged();
+        });
+
+        Assert.NotEqual(PermissionsNodeStatusKind.Starting, harness.ViewModel.NodeStatusKind);
+        harness.Dispatcher.FlushPending();
+        Assert.Equal(PermissionsNodeStatusKind.Starting, harness.ViewModel.NodeStatusKind);
+
+        harness.Dispatcher.HasThreadAccess = false;
+        harness.Dispatcher.RunEnqueuedImmediately = false;
+        await Task.Run(() => harness.RecordingExecStore!.RaiseExternalSnapshot(
+            BuildSnapshot(
+                hash: "external-valid",
+                file: BuildFile(defaultAction: "prompt", allowlist: [new ExecAllowlistEntry { Pattern = "**/git.exe" }]))));
+
+        Assert.Empty(harness.ViewModel.ExecApprovalRules);
+        harness.Dispatcher.FlushPending();
+        Assert.Single(harness.ViewModel.ExecApprovalRules);
+    }
+
+    [Fact]
+    public async Task DefaultActionAndRuleMutations_UseV2Mappings_AndPreserveMetadata()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("allow"));
+        Assert.True(File.Exists(harness.ExecApprovalsPath));
+
+        var afterAllow = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+        Assert.Equal(ExecSecurity.Full, afterAllow.Snapshot!.File.Defaults!.Security);
+        Assert.Equal(ExecAsk.Off, afterAllow.Snapshot.File.Defaults.Ask);
+        Assert.Equal(ExecSecurity.Deny, afterAllow.Snapshot.File.Defaults.AskFallback);
+        Assert.False(afterAllow.Snapshot.File.Defaults.AutoAllowSkills);
+        Assert.Equal(ExecSecurity.Full, afterAllow.Snapshot.File.Agents!["main"].Security);
+        Assert.Equal(ExecAsk.Off, afterAllow.Snapshot.File.Agents["main"].Ask);
+
+        Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+        var withRule = await harness.RealExecStore.GetSnapshotReadOnlyAsync();
+        var entry = Assert.Single(withRule.Snapshot!.File.Agents!["main"].Allowlist!);
+        Assert.Equal("**/git.exe", entry.Pattern);
+
+        Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(
+            Assert.Single(harness.ViewModel.ExecApprovalRules)));
+        var removed = await harness.RealExecStore.GetSnapshotReadOnlyAsync();
+        Assert.True(removed.Snapshot!.File.Agents!["main"].Allowlist is null or { Count: 0 });
+    }
+
+    [Fact]
+    public async Task RemoveRule_UsesCapturedIdentityAfterExternalReorder()
+    {
+        var capturedId = Guid.NewGuid();
+        var survivorId = Guid.NewGuid();
+        var insertedId = Guid.NewGuid();
+        using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot(
+            "base",
+            BuildFile(
+                defaultAction: "prompt",
+                allowlist:
+                [
+                    new ExecAllowlistEntry { Id = capturedId, Pattern = "**/git.exe" },
+                    new ExecAllowlistEntry { Id = survivorId, Pattern = "**/rg.exe" },
+                ])));
+        harness.ViewModel.Activate(null);
+        var removalToken = harness.ViewModel.ExecApprovalRules[0];
+
+        harness.RecordingExecStore!.RaiseExternalSnapshot(BuildSnapshot(
+            "reordered",
+            BuildFile(
+                defaultAction: "prompt",
+                allowlist:
+                [
+                    new ExecAllowlistEntry { Id = insertedId, Pattern = "**/pwsh.exe" },
+                    new ExecAllowlistEntry { Id = survivorId, Pattern = "**/rg.exe" },
+                    new ExecAllowlistEntry { Id = capturedId, Pattern = "**/git.exe" },
+                ])));
+
+        Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(removalToken));
+
+        var remaining = harness.RecordingExecStore.CurrentSnapshot.File.Agents!["main"].Allowlist!;
+        Assert.DoesNotContain(remaining, rule => rule.Id == capturedId);
+        Assert.Contains(remaining, rule => rule.Id == insertedId);
+        Assert.Contains(remaining, rule => rule.Id == survivorId);
+    }
+
+    [Fact]
+    public async Task RemoveIdlessRule_PatternFallbackDoesNotDeleteIdentifiedRule()
+    {
+        var identifiedId = Guid.NewGuid();
+        using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot(
+            "base",
+            BuildFile(
+                defaultAction: "prompt",
+                allowlist:
+                [
+                    new ExecAllowlistEntry { Pattern = "**/git.exe" },
+                    new ExecAllowlistEntry { Id = identifiedId, Pattern = "**/git.exe" },
+                ])));
+        harness.ViewModel.Activate(null);
+        var removalToken = Assert.Single(harness.ViewModel.ExecApprovalRules, rule => rule.Id is null);
+
+        Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(removalToken));
+
+        var remaining = Assert.Single(
+            harness.RecordingExecStore!.CurrentSnapshot.File.Agents!["main"].Allowlist!);
+        Assert.Equal(identifiedId, remaining.Id);
+    }
+
+    [Fact]
+    public async Task DefaultActionMutation_PreservesExplicitAutoAllowSkills()
+    {
+        var initial = BuildFile(defaultAction: "deny");
+        initial.Agents!["main"].AutoAllowSkills = true;
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", initial));
+        harness.ViewModel.Activate(null);
+
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("allow"));
+
+        var saved = harness.RecordingExecStore!.CurrentSnapshot.File;
+        Assert.False(saved.Defaults!.AutoAllowSkills);
+        Assert.True(saved.Agents!["main"].AutoAllowSkills);
+    }
+
+    [Fact]
+    public async Task DefaultActionMutation_PreservesDynamicDefaultAutoAllowSkillsInheritance()
+    {
+        var initial = BuildFile(defaultAction: "deny", autoAllowSkills: true);
+        initial.Agents!["main"].AutoAllowSkills = null;
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+        var current = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+        Assert.NotNull(await harness.RealExecStore.ReplaceAsync(current.Snapshot!.Hash, initial, origin: null));
+
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("allow"));
+
+        var savedResult = await harness.RealExecStore.GetSnapshotReadOnlyAsync();
+        var saved = savedResult.Snapshot!.File;
+        Assert.True(saved.Defaults!.AutoAllowSkills);
+        Assert.Null(saved.Agents!["main"].AutoAllowSkills);
+        Assert.True(harness.RealExecStore.ResolveReadOnly("main").Defaults.AutoAllowSkills);
+
+        saved.Defaults.AutoAllowSkills = false;
+        Assert.NotNull(await harness.RealExecStore.ReplaceAsync(savedResult.Snapshot.Hash, saved, origin: null));
+        Assert.False(harness.RealExecStore.ResolveReadOnly("main").Defaults.AutoAllowSkills);
+    }
+
+    [Fact]
+    public async Task DefaultActionMutation_PreservesDynamicWildcardAutoAllowSkillsInheritance()
+    {
+        var initial = BuildFile(defaultAction: "deny");
+        initial.Agents!["main"].AutoAllowSkills = null;
+        initial.Agents["*"] = new ExecApprovalsAgent { AutoAllowSkills = true };
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+        var current = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+        Assert.NotNull(await harness.RealExecStore.ReplaceAsync(current.Snapshot!.Hash, initial, origin: null));
+
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("allow"));
+
+        var savedResult = await harness.RealExecStore.GetSnapshotReadOnlyAsync();
+        var saved = savedResult.Snapshot!.File;
+        Assert.False(saved.Defaults!.AutoAllowSkills);
+        Assert.Null(saved.Agents!["main"].AutoAllowSkills);
+        Assert.True(saved.Agents["*"].AutoAllowSkills);
+        Assert.True(harness.RealExecStore.ResolveReadOnly("main").Defaults.AutoAllowSkills);
+
+        saved.Agents["*"].AutoAllowSkills = false;
+        Assert.NotNull(await harness.RealExecStore.ReplaceAsync(savedResult.Snapshot.Hash, saved, origin: null));
+        Assert.False(harness.RealExecStore.ResolveReadOnly("main").Defaults.AutoAllowSkills);
+    }
+
+    [Fact]
+    public async Task AddRuleFromDeny_PreservesExplicitAutoAllowSkills()
+    {
+        var initial = BuildFile(
+            defaultAction: "deny",
+            allowlist: [new ExecAllowlistEntry { Pattern = "**/rg.exe" }]);
+        initial.Defaults!.Security = ExecSecurity.Deny;
+        initial.Agents!["main"].Security = ExecSecurity.Deny;
+        initial.Agents["main"].AutoAllowSkills = true;
+        using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", initial));
+        harness.ViewModel.Activate(null);
+
+        Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+        var saved = harness.RecordingExecStore!.CurrentSnapshot.File;
+        Assert.False(saved.Defaults!.AutoAllowSkills);
+        Assert.True(saved.Agents!["main"].AutoAllowSkills);
+        Assert.Equal(ExecSecurity.Allowlist, saved.Agents["main"].Security);
+    }
+
+    [Fact]
+    public async Task AddRuleFromDeny_PreservesDynamicDefaultAutoAllowSkillsInheritance()
+    {
+        var initial = BuildFile(
+            defaultAction: "deny",
+            allowlist: [new ExecAllowlistEntry { Pattern = "**/rg.exe" }],
+            autoAllowSkills: true);
+        initial.Defaults!.Security = ExecSecurity.Deny;
+        initial.Agents!["main"].Security = ExecSecurity.Deny;
+        initial.Agents["main"].AutoAllowSkills = null;
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+        var current = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+        Assert.NotNull(await harness.RealExecStore.ReplaceAsync(current.Snapshot!.Hash, initial, origin: null));
+
+        Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+        var savedResult = await harness.RealExecStore.GetSnapshotReadOnlyAsync();
+        var saved = savedResult.Snapshot!.File;
+        Assert.True(saved.Defaults!.AutoAllowSkills);
+        Assert.Null(saved.Agents!["main"].AutoAllowSkills);
+        Assert.True(harness.RealExecStore.ResolveReadOnly("main").Defaults.AutoAllowSkills);
+
+        saved.Defaults.AutoAllowSkills = false;
+        Assert.NotNull(await harness.RealExecStore.ReplaceAsync(savedResult.Snapshot.Hash, saved, origin: null));
+        Assert.False(harness.RealExecStore.ResolveReadOnly("main").Defaults.AutoAllowSkills);
+    }
+
+    [Fact]
+    public async Task RemoveRuleFromAbsoluteDeny_PreservesDenyAndDormantRules()
+    {
+        var firstId = Guid.NewGuid();
+        var secondId = Guid.NewGuid();
+        var initial = BuildFile(
+            defaultAction: "deny",
+            allowlist:
+            [
+                new ExecAllowlistEntry { Id = firstId, Pattern = "**/git.exe" },
+                new ExecAllowlistEntry { Id = secondId, Pattern = "**/rg.exe" },
+            ]);
+        initial.Defaults!.Security = ExecSecurity.Deny;
+        initial.Agents!["main"].Security = ExecSecurity.Deny;
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+        var current = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+        var seeded = await harness.RealExecStore.ReplaceAsync(current.Snapshot!.Hash, initial, origin: null);
+        Assert.NotNull(seeded);
+        Assert.Equal(2, harness.ViewModel.ExecApprovalRules.Count);
+
+        Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(
+            harness.ViewModel.ExecApprovalRules[0]));
+
+        var savedResult = await harness.RealExecStore.GetSnapshotReadOnlyAsync();
+        var saved = savedResult.Snapshot!.File;
+        Assert.Equal(ExecSecurity.Deny, saved.Defaults!.Security);
+        Assert.Equal(ExecSecurity.Deny, saved.Agents!["main"].Security);
+        var remaining = Assert.Single(saved.Agents["main"].Allowlist!);
+        Assert.Equal(secondId, remaining.Id);
+        Assert.Equal("**/rg.exe", remaining.Pattern);
+        Assert.Equal(ExecSecurity.Deny, harness.RealExecStore.ResolveReadOnly("main").Defaults.Security);
+    }
+
+    [Fact]
+    public async Task DefaultActionSaveFailure_RetainsPersistedPresentation()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "prompt")));
+        harness.ViewModel.Activate(null);
+        harness.RecordingExecStore!.OnReplace = _ => throw new IOException("write blocked");
+
+        Assert.False(await harness.ViewModel.SetDefaultExecActionAsync("deny"));
+
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.Equal(ExecSecurity.Allowlist, harness.RecordingExecStore.CurrentSnapshot.File.Defaults!.Security);
+        Assert.Equal(ExecAsk.OnMiss, harness.RecordingExecStore.CurrentSnapshot.File.Defaults.Ask);
+        Assert.Equal(PermissionsExecApprovalsStatus.SaveFailed, harness.ViewModel.ExecApprovalsStatus);
+    }
+
+    [Fact]
+    public async Task ConcurrentMutations_CommitInInvocationOrder_AndPreserveBothEdits()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "deny")));
+        harness.ViewModel.Activate(null);
+        var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        harness.RecordingExecStore!.OnReplaceAsync = async call =>
+        {
+            if (call.Attempt == 1)
+            {
+                firstEntered.SetResult();
+                await releaseFirst.Task;
+            }
+
+            return harness.RecordingExecStore.Commit(call.Replacement, $"saved-{call.Attempt}");
+        };
+
+        var first = harness.ViewModel.SetDefaultExecActionAsync("prompt");
+        await firstEntered.Task;
+        var second = harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe");
+        await Task.Delay(50);
+
+        Assert.Single(harness.RecordingExecStore.ReplaceCalls);
+        releaseFirst.SetResult();
+        Assert.True(await first);
+        Assert.True(await second);
+
+        Assert.Equal(2, harness.RecordingExecStore.ReplaceCalls.Count);
+        var saved = harness.RecordingExecStore.CurrentSnapshot.File;
+        Assert.Equal(ExecSecurity.Allowlist, saved.Defaults!.Security);
+        Assert.Equal(ExecAsk.OnMiss, saved.Defaults.Ask);
+        Assert.Equal("**/git.exe", Assert.Single(saved.Agents!["main"].Allowlist!).Pattern);
+    }
+
+    [Fact]
+    public async Task DelayedActivationRefresh_DoesNotOverwriteNewerMutation()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "prompt")));
+        var initialReadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseInitialRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var readCount = 0;
+        harness.RecordingExecStore!.OnGetSnapshotAsync = async () =>
+        {
+            var captured = harness.RecordingExecStore.CreateReadResult();
+            if (Interlocked.Increment(ref readCount) == 1)
+            {
+                initialReadStarted.SetResult();
+                await releaseInitialRead.Task;
+            }
+
+            return captured;
+        };
+
+        harness.ViewModel.Activate(null);
+        await initialReadStarted.Task;
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("allow"));
+
+        releaseInitialRead.SetResult();
+        await Task.Delay(50);
+
+        Assert.Equal("allow", harness.ViewModel.DefaultExecActionTag);
+        Assert.Equal(ExecSecurity.Full, harness.RecordingExecStore.CurrentSnapshot.File.Defaults!.Security);
+    }
+
+    [Fact]
+    public async Task AddRuleFromMissingPolicy_PreservesFreshV2Defaults()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+
+        Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+        var saved = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+        Assert.Equal(ExecSecurity.Allowlist, saved.Snapshot!.File.Defaults!.Security);
+        Assert.Equal(ExecAsk.OnMiss, saved.Snapshot.File.Defaults.Ask);
+        Assert.Equal("**/git.exe", Assert.Single(saved.Snapshot.File.Agents!["main"].Allowlist!).Pattern);
+        var resolved = harness.RealExecStore.ResolveReadOnly("main");
+        Assert.Equal(ExecSecurity.Allowlist, resolved.Defaults.Security);
+        Assert.Equal(ExecAsk.OnMiss, resolved.Defaults.Ask);
+    }
+
+    [Fact]
+    public async Task InvalidPattern_DoesNotWrite()
+    {
+        using var harness = PermissionsHarness.CreateReal();
+        harness.ViewModel.Activate(null);
+
+        Assert.False(ExecApprovalsStore.IsValidAllowlistPattern("["));
+        Assert.False(await harness.ViewModel.TryAddExecApprovalRuleAsync("["));
+        Assert.False(File.Exists(harness.ExecApprovalsPath));
+    }
+
+    [Fact]
+    public async Task CasConflict_RetriesAgainstFreshSnapshots_AndPreservesUnrelatedFields()
+    {
+        var initial = BuildSnapshot(
+            hash: "base",
+            file: BuildFile(
+                defaultAction: "deny",
+                socketToken: "socket-1",
+                otherAgentPath: "**/rg.exe"));
+        using var harness = PermissionsHarness.CreateWithRecordingStore(initial);
+        harness.ViewModel.Activate(null);
+
+        harness.RecordingExecStore!.OnReplace = call =>
+        {
+            if (call.Attempt == 1)
+            {
+                harness.RecordingExecStore.ReplaceCurrentSnapshot(BuildSnapshot(
+                    hash: "fresh",
+                    file: BuildFile(
+                        defaultAction: "deny",
+                        socketToken: "socket-2",
+                        otherAgentPath: "**/rg.exe")));
+                return null;
+            }
+
+            return harness.RecordingExecStore.Commit(call.Replacement, "saved");
+        };
+
+        Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+        var saved = harness.RecordingExecStore.CurrentSnapshot;
+        Assert.Equal("socket-2", saved.File.Socket!.Token);
+        Assert.Equal("**/rg.exe", saved.File.Agents!["other"].Allowlist![0].Pattern);
+        Assert.Equal("**/git.exe", saved.File.Agents!["main"].Allowlist![0].Pattern);
+        Assert.Equal(2, harness.RecordingExecStore.ReplaceCalls.Count);
+    }
+
+    [Fact]
+    public async Task RemoveRule_CasRetry_DoesNotDeleteConcurrentReplacementWithSamePattern()
+    {
+        var originalId = Guid.NewGuid();
+        var replacementId = Guid.NewGuid();
+        using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot(
+            hash: "base",
+            file: BuildFile(
+                defaultAction: "prompt",
+                allowlist: [new ExecAllowlistEntry { Id = originalId, Pattern = "**/git.exe" }])));
+        harness.ViewModel.Activate(null);
+        harness.RecordingExecStore!.OnReplace = call =>
+        {
+            if (call.Attempt == 1)
+            {
+                harness.RecordingExecStore.ReplaceCurrentSnapshot(BuildSnapshot(
+                    hash: "fresh",
+                    file: BuildFile(
+                        defaultAction: "prompt",
+                        allowlist: [new ExecAllowlistEntry { Id = replacementId, Pattern = "**/git.exe" }])));
+                return null;
+            }
+
+            return harness.RecordingExecStore.Commit(call.Replacement, "saved");
+        };
+
+        var removalToken = Assert.Single(harness.ViewModel.ExecApprovalRules);
+        Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(removalToken));
+
+        var saved = harness.RecordingExecStore.CurrentSnapshot.File;
+        var remaining = Assert.Single(saved.Agents!["main"].Allowlist!);
+        Assert.Equal(replacementId, remaining.Id);
+        Assert.Equal("**/git.exe", remaining.Pattern);
+        Assert.Equal(2, harness.RecordingExecStore.ReplaceCalls.Count);
+    }
+
+    [Fact]
+    public async Task MutationCompletion_AfterExternalChange_RefreshesWithoutStaleSavedState()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "prompt")));
+        harness.ViewModel.Activate(null);
+        var mutationCommitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseMutation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        harness.RecordingExecStore!.OnReplaceAsync = async call =>
+        {
+            var committed = harness.RecordingExecStore.Commit(call.Replacement, "mutation");
+            mutationCommitted.SetResult();
+            await releaseMutation.Task;
+            return committed;
+        };
+
+        var mutation = harness.ViewModel.SetDefaultExecActionAsync("allow");
+        await mutationCommitted.Task;
+        harness.RecordingExecStore.RaiseExternalSnapshot(BuildSnapshot(
+            "external",
+            BuildFile(defaultAction: "deny")));
+        releaseMutation.SetResult();
+
+        Assert.True(await mutation);
+        Assert.Equal("deny", harness.ViewModel.DefaultExecActionTag);
+        Assert.NotEqual(PermissionsExecApprovalsStatus.Saved, harness.ViewModel.ExecApprovalsStatus);
+    }
+
+    [Fact]
+    public async Task MutationCompletion_AfterDeactivate_DoesNotUpdatePresentation()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "prompt")));
+        harness.ViewModel.Activate(null);
+        var mutationCommitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseMutation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+        harness.RecordingExecStore!.OnReplaceAsync = async call =>
+        {
+            var committed = harness.RecordingExecStore.Commit(call.Replacement, "mutation");
+            mutationCommitted.SetResult();
+            await releaseMutation.Task;
+            return committed;
+        };
+
+        var mutation = harness.ViewModel.SetDefaultExecActionAsync("allow");
+        await mutationCommitted.Task;
+        harness.ViewModel.Deactivate();
+        releaseMutation.SetResult();
+
+        Assert.True(await mutation);
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.NotEqual(PermissionsExecApprovalsStatus.Saved, harness.ViewModel.ExecApprovalsStatus);
+    }
+
+    [Fact]
+    public async Task ExecApprovalsChanges_RejectOlderSequenceAfterNewerOwnOriginEvent()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "deny")));
+        harness.ViewModel.Activate(null);
+        harness.RecordingExecStore!.OnReplace = call =>
+        {
+            var committed = harness.RecordingExecStore.Commit(call.Replacement, "own");
+            harness.RecordingExecStore.RaiseSnapshot(committed, sequence: 2, origin: call.Origin);
+            return committed;
+        };
+
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("prompt"));
+        harness.RecordingExecStore.RaiseSnapshot(
+            BuildSnapshot("older-external", BuildFile(defaultAction: "allow")),
+            sequence: 1,
+            origin: null);
+
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.Equal(PermissionsExecApprovalsStatus.Saved, harness.ViewModel.ExecApprovalsStatus);
+    }
+
+    [Fact]
+    public async Task ExecApprovalsChanges_QueuedOlderExternalDoesNotApplyAfterNewerOwnOriginEvent()
+    {
+        using var harness = PermissionsHarness.CreateWithRecordingStore(
+            BuildSnapshot("base", BuildFile(defaultAction: "deny")));
+        harness.ViewModel.Activate(null);
+        harness.Dispatcher.HasThreadAccess = false;
+        harness.Dispatcher.RunEnqueuedImmediately = false;
+        var defaultActionChanges = 0;
+        harness.ViewModel.PropertyChanged += (_, args) =>
+        {
+            if (args.PropertyName == nameof(PermissionsPageViewModel.DefaultExecActionTag))
+            {
+                defaultActionChanges++;
+            }
+        };
+
+        harness.RecordingExecStore!.RaiseSnapshot(
+            BuildSnapshot("older-external", BuildFile(defaultAction: "allow")),
+            sequence: 1,
+            origin: null);
+        harness.RecordingExecStore.OnReplace = call =>
+        {
+            var committed = harness.RecordingExecStore.Commit(call.Replacement, "own");
+            harness.RecordingExecStore.RaiseSnapshot(committed, sequence: 2, origin: call.Origin);
+            return committed;
+        };
+
+        Assert.True(await harness.ViewModel.SetDefaultExecActionAsync("prompt"));
+        Assert.Equal("deny", harness.ViewModel.DefaultExecActionTag);
+
+        harness.Dispatcher.FlushPending();
+
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.Equal(1, defaultActionChanges);
+        Assert.Equal(PermissionsExecApprovalsStatus.Saved, harness.ViewModel.ExecApprovalsStatus);
+    }
+
+    [Fact]
+    public void ExternalValidChange_UpdatesOnce_AndCorruptRetainsLastValidDisplay()
+    {
+        var initial = BuildSnapshot(
+            hash: "initial",
+            file: BuildFile(defaultAction: "deny"));
+        using var harness = PermissionsHarness.CreateWithRecordingStore(initial);
+        harness.ViewModel.Activate(null);
+
+        var propertyChanges = 0;
+        harness.ViewModel.PropertyChanged += (_, args) =>
+        {
+            if (args.PropertyName == nameof(PermissionsPageViewModel.DefaultExecActionTag))
+            {
+                propertyChanges++;
+            }
+        };
+
+        harness.RecordingExecStore!.RaiseExternalSnapshot(BuildSnapshot(
+            hash: "valid-1",
+            file: BuildFile(defaultAction: "prompt", allowlist: [new ExecAllowlistEntry { Pattern = "**/git.exe" }])));
+
+        Assert.Equal(1, propertyChanges);
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.Single(harness.ViewModel.ExecApprovalRules);
+
+        harness.RecordingExecStore.RaiseExternalInvalid(
+            new ExecApprovalsSnapshotFailure(
+                ExecApprovalsSnapshotFailureKind.MalformedJson,
+                "bad-hash",
+                null,
+                "malformed"),
+            harness.RecordingExecStore.CurrentSnapshot);
+
+        Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+        Assert.Single(harness.ViewModel.ExecApprovalRules);
+        Assert.Equal(ExecApprovalsSnapshotFailureKind.MalformedJson, harness.ViewModel.ExecApprovalsFailure!.Kind);
+        Assert.Equal(PermissionsExecApprovalsStatus.ExternalInvalid, harness.ViewModel.ExecApprovalsStatus);
+    }
+
+    private static ExecApprovalsSnapshot BuildSnapshot(string hash, ExecApprovalsFile file) =>
+        new("D:\\exec-approvals.json", true, hash, file);
+
+    private static void SetCapability(ISettingsEditor edit, PermissionsCapabilityKey key, bool value)
+    {
+        switch (key)
+        {
+            case PermissionsCapabilityKey.SystemRun:
+                edit.NodeSystemRunEnabled = value;
+                break;
+            case PermissionsCapabilityKey.BrowserProxy:
+                edit.NodeBrowserProxyEnabled = value;
+                break;
+            case PermissionsCapabilityKey.Camera:
+                edit.NodeCameraEnabled = value;
+                break;
+            case PermissionsCapabilityKey.Canvas:
+                edit.NodeCanvasEnabled = value;
+                break;
+            case PermissionsCapabilityKey.Screen:
+                edit.NodeScreenEnabled = value;
+                break;
+            case PermissionsCapabilityKey.Location:
+                edit.NodeLocationEnabled = value;
+                break;
+            case PermissionsCapabilityKey.TextToSpeech:
+                edit.NodeTtsEnabled = value;
+                break;
+            case PermissionsCapabilityKey.SpeechToText:
+                edit.NodeSttEnabled = value;
+                break;
+        }
+    }
+
+    private static ExecApprovalsFile BuildFile(
+        string defaultAction,
+        IEnumerable<ExecAllowlistEntry>? allowlist = null,
+        string? socketToken = null,
+        string? otherAgentPath = null,
+        bool autoAllowSkills = false)
+    {
+        var (security, ask) = defaultAction switch
+        {
+            "allow" => (ExecSecurity.Full, ExecAsk.Off),
+            "prompt" => (ExecSecurity.Allowlist, ExecAsk.OnMiss),
+            _ => (ExecSecurity.Allowlist, ExecAsk.Off),
+        };
+
+        return new ExecApprovalsFile
+        {
+            Version = 1,
+            Socket = socketToken is null ? null : new ExecApprovalsSocketConfig { Token = socketToken, Path = "socket" },
+            Defaults = new ExecApprovalsDefaults
+            {
+                Security = security,
+                Ask = ask,
+                AskFallback = ExecSecurity.Deny,
+                AutoAllowSkills = autoAllowSkills,
+            },
+            Agents = new Dictionary<string, ExecApprovalsAgent>(StringComparer.Ordinal)
+            {
+                ["main"] = new ExecApprovalsAgent
+                {
+                    Security = security,
+                    Ask = ask,
+                    AskFallback = ExecSecurity.Deny,
+                    AutoAllowSkills = autoAllowSkills,
+                    Allowlist = allowlist?.ToList() ?? [],
+                },
+                ["other"] = new ExecApprovalsAgent
+                {
+                    Allowlist = otherAgentPath is null ? [] : [new ExecAllowlistEntry { Pattern = otherAgentPath }],
+                },
+            },
+        };
+    }
+
+    private sealed class PermissionsHarness : IDisposable
+    {
+        private PermissionsHarness(
+            TempDir temp,
+            SettingsManager settings,
+            SettingsStore settingsStore,
+            FakeAppCommands commands,
+            RecordingUiDispatcher dispatcher,
+            FakePermissionsPageRuntimeHost runtimeHost,
+            IPermissionsPageRuntimeSource runtimeSource,
+            PermissionsPageViewModel viewModel,
+            IExecApprovalsPresentationStore execApprovalsStore)
+        {
+            Temp = temp;
+            Settings = settings;
+            SettingsStore = settingsStore;
+            Commands = commands;
+            Dispatcher = dispatcher;
+            RuntimeHost = runtimeHost;
+            RuntimeSource = runtimeSource;
+            ViewModel = viewModel;
+            ExecApprovalsStore = execApprovalsStore;
+            ExecApprovalsPath = OpenClaw.Shared.ExecApprovals.ExecApprovalsStore.ResolveFilePath(temp.Path);
+        }
+
+        public TempDir Temp { get; }
+        public SettingsManager Settings { get; }
+        public SettingsStore SettingsStore { get; }
+        public FakeAppCommands Commands { get; }
+        public RecordingUiDispatcher Dispatcher { get; }
+        public FakePermissionsPageRuntimeHost RuntimeHost { get; }
+        public IPermissionsPageRuntimeSource RuntimeSource { get; }
+        public PermissionsPageViewModel ViewModel { get; }
+        public IExecApprovalsPresentationStore ExecApprovalsStore { get; }
+        public string ExecApprovalsPath { get; }
+        public ExecApprovalsStore? RealExecStore => ExecApprovalsStore as ExecApprovalsStore;
+        public RecordingExecApprovalsStore? RecordingExecStore => ExecApprovalsStore as RecordingExecApprovalsStore;
+
+        public static PermissionsHarness CreateReal()
+        {
+            var temp = new TempDir();
+            var settings = new SettingsManager(temp.Path);
+            var commands = new FakeAppCommands();
+            var dispatcher = new RecordingUiDispatcher();
+            var settingsStore = new SettingsStore(settings, dispatcher);
+            var runtimeHost = new FakePermissionsPageRuntimeHost();
+            var runtimeSource = new PermissionsPageRuntimeSource(runtimeHost);
+            var execStore = new ExecApprovalsStore(temp.Path, NullLogger.Instance);
+            var viewModel = new PermissionsPageViewModel(settingsStore, execStore, commands, dispatcher, runtimeSource);
+            return new PermissionsHarness(temp, settings, settingsStore, commands, dispatcher, runtimeHost, runtimeSource, viewModel, execStore);
+        }
+
+        public static PermissionsHarness CreateWithRecordingStore(ExecApprovalsSnapshot? initial = null)
+        {
+            var temp = new TempDir();
+            var settings = new SettingsManager(temp.Path);
+            var dispatcher = new RecordingUiDispatcher();
+            var settingsStore = new SettingsStore(settings, dispatcher);
+            var commands = new FakeAppCommands();
+            var runtimeHost = new FakePermissionsPageRuntimeHost();
+            var runtimeSource = new PermissionsPageRuntimeSource(runtimeHost);
+            var execStore = new RecordingExecApprovalsStore(initial);
+            var viewModel = new PermissionsPageViewModel(settingsStore, execStore, commands, dispatcher, runtimeSource);
+            return new PermissionsHarness(temp, settings, settingsStore, commands, dispatcher, runtimeHost, runtimeSource, viewModel, execStore);
+        }
+
+        public void Dispose()
+        {
+            ViewModel.Dispose();
+            SettingsStore.Dispose();
+            if (ExecApprovalsStore is IDisposable disposableExecStore)
+            {
+                disposableExecStore.Dispose();
+            }
+            if (RuntimeSource is IDisposable disposableRuntimeSource)
+            {
+                disposableRuntimeSource.Dispose();
+            }
+            Temp.Dispose();
+        }
+    }
+
+    private sealed class RecordingExecApprovalsStore : IExecApprovalsPresentationStore
+    {
+        private static readonly ConstructorInfo OriginCtor =
+            typeof(ExecApprovalsWriterOrigin).GetConstructor(
+                BindingFlags.Instance | BindingFlags.NonPublic,
+                binder: null,
+                Type.EmptyTypes,
+                modifiers: null)!;
+
+        private ExecApprovalsSnapshot _current;
+        private ExecApprovalsSnapshot? _lastValid;
+        private long _nextSequence;
+
+        public RecordingExecApprovalsStore(ExecApprovalsSnapshot? initial = null)
+        {
+            _current = initial ?? BuildSnapshot("initial", BuildFile(defaultAction: "deny"));
+            _lastValid = _current;
+        }
+
+        public event EventHandler<ExecApprovalsChangedEventArgs>? Changed;
+
+        public List<ReplaceCall> ReplaceCalls { get; } = new();
+        public Func<ReplaceCall, ExecApprovalsSnapshot?>? OnReplace { get; set; }
+        public Func<ReplaceCall, Task<ExecApprovalsSnapshot?>>? OnReplaceAsync { get; set; }
+        public Func<Task<ExecApprovalsReadOnlySnapshotResult>>? OnGetSnapshotAsync { get; set; }
+        public ExecApprovalsSnapshot CurrentSnapshot => _current;
+
+        public Task<ExecApprovalsReadOnlySnapshotResult> GetSnapshotReadOnlyAsync(CancellationToken cancellationToken = default) =>
+            OnGetSnapshotAsync?.Invoke() ?? Task.FromResult(CreateReadResult());
+
+        public ExecApprovalsReadOnlySnapshotResult CreateReadResult() =>
+            new(CloneSnapshot(_current), null, null);
+
+        public ExecApprovalsWriterOrigin CreateWriterOrigin() =>
+            (ExecApprovalsWriterOrigin)OriginCtor.Invoke(null);
+
+        public async Task<ExecApprovalsSnapshot?> ReplaceAsync(
+            string baseHash,
+            ExecApprovalsFile replacement,
+            ExecApprovalsWriterOrigin? origin,
+            Func<ExecApprovalsFile, ExecApprovalsFile, string?>? deltaValidator = null)
+        {
+            var validationError = deltaValidator?.Invoke(_current.File, replacement);
+            if (!string.IsNullOrWhiteSpace(validationError))
+            {
+                throw new IOException(validationError);
+            }
+
+            var call = new ReplaceCall(ReplaceCalls.Count + 1, baseHash, CloneFile(replacement), origin);
+            ReplaceCalls.Add(call);
+            var currentHashBeforeCallback = _current.Hash;
+            var result = OnReplaceAsync is not null
+                ? await OnReplaceAsync(call)
+                : OnReplace?.Invoke(call);
+            if (result is not null)
+            {
+                if (string.Equals(_current.Hash, currentHashBeforeCallback, StringComparison.Ordinal)
+                    || string.Equals(_current.Hash, result.Hash, StringComparison.Ordinal))
+                {
+                    _current = CloneSnapshot(result);
+                    _lastValid = _current;
+                }
+
+                return CloneSnapshot(result);
+            }
+
+            if (!string.Equals(baseHash, _current.Hash, StringComparison.Ordinal))
+            {
+                return null;
+            }
+
+            return Commit(replacement, $"hash-{ReplaceCalls.Count}");
+        }
+
+        public ExecApprovalsSnapshot Commit(ExecApprovalsFile replacement, string hash)
+        {
+            _current = new ExecApprovalsSnapshot(_current.Path, true, hash, CloneFile(replacement));
+            _lastValid = _current;
+            return CloneSnapshot(_current);
+        }
+
+        public void ReplaceCurrentSnapshot(ExecApprovalsSnapshot snapshot)
+        {
+            _current = CloneSnapshot(snapshot);
+            _lastValid = _current;
+        }
+
+        public void RaiseExternalSnapshot(ExecApprovalsSnapshot snapshot)
+            => RaiseSnapshot(snapshot, Interlocked.Increment(ref _nextSequence), origin: null);
+
+        public void RaiseSnapshot(
+            ExecApprovalsSnapshot snapshot,
+            long sequence,
+            ExecApprovalsWriterOrigin? origin)
+        {
+            _current = CloneSnapshot(snapshot);
+            _lastValid = _current;
+            Changed?.Invoke(this, new ExecApprovalsChangedEventArgs(
+                sequence,
+                ExecApprovalsChangeKind.SnapshotUpdated,
+                snapshot.Hash,
+                snapshot.File.Version,
+                CloneSnapshot(snapshot),
+                failure: null,
+                lastValidSnapshot: null,
+                origin));
+        }
+
+        public void RaiseExternalInvalid(ExecApprovalsSnapshotFailure failure, ExecApprovalsSnapshot? lastValidSnapshot)
+        {
+            Changed?.Invoke(this, new ExecApprovalsChangedEventArgs(
+                Interlocked.Increment(ref _nextSequence),
+                ExecApprovalsChangeKind.SnapshotInvalid,
+                failure.Hash,
+                failure.Version,
+                snapshot: null,
+                failure,
+                lastValidSnapshot is null ? null : CloneSnapshot(lastValidSnapshot),
+                origin: null));
+        }
+
+        private static ExecApprovalsSnapshot CloneSnapshot(ExecApprovalsSnapshot snapshot) =>
+            new(snapshot.Path, snapshot.Exists, snapshot.Hash, CloneFile(snapshot.File));
+
+        private static ExecApprovalsFile CloneFile(ExecApprovalsFile file) =>
+            new()
+            {
+                Version = file.Version,
+                Socket = file.Socket is null ? null : new ExecApprovalsSocketConfig
+                {
+                    Path = file.Socket.Path,
+                    Token = file.Socket.Token,
+                },
+                Defaults = file.Defaults is null ? null : new ExecApprovalsDefaults
+                {
+                    Security = file.Defaults.Security,
+                    Ask = file.Defaults.Ask,
+                    AskFallback = file.Defaults.AskFallback,
+                    AutoAllowSkills = file.Defaults.AutoAllowSkills,
+                },
+                Agents = file.Agents?.ToDictionary(
+                    pair => pair.Key,
+                    pair => new ExecApprovalsAgent
+                    {
+                        Security = pair.Value.Security,
+                        Ask = pair.Value.Ask,
+                        AskFallback = pair.Value.AskFallback,
+                        AutoAllowSkills = pair.Value.AutoAllowSkills,
+                        Allowlist = pair.Value.Allowlist?.Select(entry => new ExecAllowlistEntry
+                        {
+                            Id = entry.Id,
+                            Pattern = entry.Pattern,
+                            LastUsedAt = entry.LastUsedAt,
+                            LastResolvedPath = entry.LastResolvedPath,
+                        }).ToList(),
+                    },
+                    StringComparer.Ordinal),
+            };
+    }
+
+    private sealed record ReplaceCall(
+        int Attempt,
+        string BaseHash,
+        ExecApprovalsFile Replacement,
+        ExecApprovalsWriterOrigin? Origin);
+}
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PresentationSeamContractTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PresentationSeamContractTests.cs
index 0603dd5bf..e947e64b0 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/PresentationSeamContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/PresentationSeamContractTests.cs
@@ -90,6 +90,17 @@ public void App_AppliesToolCallVisibilityFromPersistedSettings()
         Assert.Contains("_settings.ShowChatToolCalls", settingsSavedBlock);
     }
 
+    [Fact]
+    public void App_PublicSpeakerMute_PublishesNullOriginThroughSettingsStore()
+    {
+        var source = ReadAppSources();
+        var publicIdx = source.IndexOf("public void SetChatSpeakerMuted", StringComparison.Ordinal);
+        Assert.True(publicIdx >= 0, "Expected a public SetChatSpeakerMuted for chat and voice surfaces.");
+        var publicBlock = source.Substring(publicIdx, Math.Min(500, source.Length - publicIdx));
+        Assert.Contains("origin: null", publicBlock);
+        Assert.Contains("store.Update(origin, edit => edit.VoiceTtsEnabled = !muted)", publicBlock);
+    }
+
     [Fact]
     public void HubWindow_InvokesPageActivator_OnNavigation()
     {
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs b/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs
index 4372b6c8f..397f6fcdf 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/PresentationTestDoubles.cs
@@ -1,5 +1,9 @@
+using OpenClaw.Connection;
+using OpenClaw.Shared;
 using OpenClawTray.Presentation;
 using OpenClawTray.Services;
+using System.Text.Json;
+using System.Threading;
 
 namespace OpenClaw.Tray.Tests.Presentation;
 
@@ -47,9 +51,14 @@ public void FlushPending()
 
 internal sealed class FakeAppCommands : IAppCommands, IDisposable
 {
+    private readonly List<string> _operationLog = new();
+
     public List<string> Navigations { get; } = new();
+    public IReadOnlyList<string> OperationLog => _operationLog;
     public bool Disposed { get; private set; }
 
+    public void ClearOperationLog() => _operationLog.Clear();
+
     public void OpenDashboard(string? path = null) { }
     public void Navigate(string pageTag) => Navigations.Add(pageTag);
     public void Reconnect() { }
@@ -60,15 +69,22 @@ public void CheckForUpdates() { }
     public void ShowOnboarding() { }
     public void ShowGatewayWizard() { }
     public void ShowConnectionStatus() { }
-    public void NotifySettingsSaved() => NotifySettingsSavedCount++;
-    public Task<bool> ApplyAutoStart(bool autoStart)
+    public void NotifySettingsSaved()
     {
+        NotifySettingsSavedCount++;
+        _operationLog.Add("notify");
+    }
+    public Task<bool> ApplyAutoStart(SettingsWriteOrigin origin, bool autoStart)
+    {
+        LastAutoStartOrigin = origin;
         AutoStartApplied = autoStart;
         AutoStartApplyCount++;
+        _operationLog.Add($"autostart:{autoStart}");
         return Task.FromResult(AutoStartResult);
     }
     public Task<bool> ResendOpenTelemetryProbeAsync() => Task.FromResult(true);
 
+    public SettingsWriteOrigin? LastAutoStartOrigin { get; private set; }
     public bool? AutoStartApplied { get; private set; }
     public int AutoStartApplyCount { get; private set; }
 
@@ -79,21 +95,35 @@ public Task<bool> ApplyAutoStart(bool autoStart)
     public void Dispose() => Disposed = true;
 }
 
+internal sealed class FakePermissionsPageRuntimeHost : IPermissionsPageRuntimeHost
+{
+    public event EventHandler? Changed;
+
+    public GatewayConnectionSnapshot ConnectionSnapshot { get; set; } = GatewayConnectionSnapshot.Idle;
+    public GatewayNodeInfo[] Nodes { get; set; } = Array.Empty<GatewayNodeInfo>();
+    public string? LocalNodeDeviceId { get; set; }
+    public JsonElement? GatewayConfig { get; set; }
+    public string? McpStartupError { get; set; }
+    public string McpEndpoint { get; set; } = "http://127.0.0.1:8765/mcp";
+    public bool IsMcpTokenReady { get; set; }
+    public int McpServedCapabilityCount { get; set; }
+    public PermissionsVoiceSetupRequirement VoiceSetupRequirement { get; set; }
+
+    public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
+}
+
 /// <summary>
-/// App-commands double that mimics the real App-owned settings writes: it persists directly to a
-/// real <see cref="SettingsManager"/> and marks the save as a store self-write via
-/// <see cref="ISettingsStore.BeginSelfWrite"/>, so tests can prove those writes do not echo an
-/// external-change reload (unlike <see cref="FakeAppCommands"/>, which no-ops the save).
+/// App-commands double that mimics the real App-owned settings writes: it persists through the
+/// shared store with the supplied writer token so tests can prove those writes notify other
+/// listeners while the originating view model ignores its own change event.
 /// </summary>
 internal sealed class SelfWritingAppCommands : IAppCommands
 {
     private readonly ISettingsStore _store;
-    private readonly SettingsManager _settings;
 
-    public SelfWritingAppCommands(ISettingsStore store, SettingsManager settings)
+    public SelfWritingAppCommands(ISettingsStore store)
     {
         _store = store;
-        _settings = settings;
     }
 
     public void OpenDashboard(string? path = null) { }
@@ -108,13 +138,9 @@ public void ShowGatewayWizard() { }
     public void ShowConnectionStatus() { }
     public void NotifySettingsSaved() { }
 
-    public Task<bool> ApplyAutoStart(bool autoStart)
+    public Task<bool> ApplyAutoStart(SettingsWriteOrigin origin, bool autoStart)
     {
-        _settings.AutoStart = autoStart;
-        using (_store.BeginSelfWrite())
-        {
-            _settings.Save();
-        }
+        _store.Update(origin, edit => edit.AutoStart = autoStart);
         return Task.FromResult(true);
     }
 
@@ -258,7 +284,22 @@ public void Dispose()
     {
         if (Directory.Exists(Path))
         {
-            Directory.Delete(Path, true);
+            for (var attempt = 0; attempt < 5; attempt++)
+            {
+                try
+                {
+                    Directory.Delete(Path, true);
+                    break;
+                }
+                catch (IOException) when (attempt < 4)
+                {
+                    Thread.Sleep(50);
+                }
+                catch (UnauthorizedAccessException) when (attempt < 4)
+                {
+                    Thread.Sleep(50);
+                }
+            }
         }
     }
 }
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs
index 7df6ae0d7..b5d82f6b8 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs
@@ -111,6 +111,7 @@ public void AutoStart_AppliedThroughAppCommand_FlashesSavedOnlyOnSuccess()
             // With a completed (successful) task, the saved flash runs after the awaited apply.
             Assert.Equal((bool?)target, appCommands.AutoStartApplied);
             Assert.Equal(1, appCommands.AutoStartApplyCount);
+            Assert.NotNull(appCommands.LastAutoStartOrigin);
             Assert.Equal(0, appCommands.NotifySettingsSavedCount);
             Assert.Equal(1, savedFlashes);
         }
@@ -141,7 +142,7 @@ public void SelfWrite_AutoStartOrMute_DoesNotTriggerExternalReload()
         var settings = new SettingsManager(temp.Path);
         var dispatcher = new RecordingUiDispatcher();
         var store = new SettingsStore(settings, dispatcher);
-        var appCommands = new SelfWritingAppCommands(store, settings);
+        var appCommands = new SelfWritingAppCommands(store);
         var vm = new SettingsPageViewModel(store, appCommands);
 
         vm.Activate(null);
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsSharedStateContractTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsSharedStateContractTests.cs
new file mode 100644
index 000000000..74c7c1c4a
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsSharedStateContractTests.cs
@@ -0,0 +1,373 @@
+using System.Reflection;
+using OpenClaw.Shared.ExecApprovals;
+using OpenClawTray.Presentation;
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests.Presentation;
+
+public sealed class SettingsSharedStateContractTests
+{
+    private static SettingsStore NewStore(out SettingsManager settings, out RecordingUiDispatcher dispatcher, out TempDir temp)
+    {
+        temp = new TempDir();
+        settings = new SettingsManager(temp.Path);
+        dispatcher = new RecordingUiDispatcher();
+        return new SettingsStore(settings, dispatcher);
+    }
+
+    private static PermissionsPageViewModel NewPermissionsVm(
+        ISettingsStore store,
+        SettingsManager settings,
+        RecordingUiDispatcher dispatcher,
+        TempDir temp)
+    {
+        var runtimeHost = new FakePermissionsPageRuntimeHost();
+        var runtimeSource = new PermissionsPageRuntimeSource(runtimeHost);
+        var execStore = new ExecApprovalsStore(temp.Path, OpenClaw.Shared.NullLogger.Instance);
+        return new PermissionsPageViewModel(store, execStore, new FakeAppCommands(), dispatcher, runtimeSource);
+    }
+
+    [Fact]
+    public void ChangedEvent_UsesVersionedOriginAwareEventArgs()
+    {
+        var changed = typeof(ISettingsStore).GetEvent(nameof(ISettingsStore.Changed));
+        Assert.NotNull(changed);
+
+        var handlerType = changed!.EventHandlerType;
+        Assert.NotNull(handlerType);
+        Assert.True(
+            handlerType!.IsGenericType &&
+            handlerType.GetGenericTypeDefinition() == typeof(EventHandler<>),
+            "Changed should expose typed event args so listeners can distinguish origin and version.");
+
+        var argsType = handlerType.GetGenericArguments()[0];
+        Assert.Equal(typeof(SettingsChangedEventArgs), argsType);
+        Assert.NotNull(argsType.GetProperty(nameof(SettingsChangedEventArgs.Origin), BindingFlags.Instance | BindingFlags.Public));
+        Assert.NotNull(argsType.GetProperty(nameof(SettingsChangedEventArgs.Version), BindingFlags.Instance | BindingFlags.Public));
+    }
+
+    [Fact]
+    public void ExternalSave_UsesNullOrigin_AndReloadsBothActiveViewModels()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var firstVm = new SettingsPageViewModel(store, new FakeAppCommands());
+            var secondVm = new SettingsPageViewModel(store, new FakeAppCommands());
+            firstVm.Activate(null);
+            secondVm.Activate(null);
+
+            var firstExternal = 0;
+            var secondExternal = 0;
+            SettingsChangedEventArgs? changed = null;
+            firstVm.ExternalChanged += (_, _) => firstExternal++;
+            secondVm.ExternalChanged += (_, _) => secondExternal++;
+            store.Changed += (_, args) => changed = args;
+
+            settings.NotifyBuild = false;
+            settings.Save();
+
+            Assert.NotNull(changed);
+            Assert.Null(changed!.Origin);
+            Assert.Equal(1, changed.Version);
+            Assert.Equal(1, firstExternal);
+            Assert.Equal(1, secondExternal);
+            Assert.False(firstVm.NotifyBuild);
+            Assert.False(secondVm.NotifyBuild);
+        }
+    }
+
+    [Fact]
+    public void TwoActiveSettingsPageViewModels_IgnoreOnlyOwnWrites_InBothDirections()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var firstCommands = new FakeAppCommands();
+            var secondCommands = new FakeAppCommands();
+            var firstVm = new SettingsPageViewModel(store, firstCommands);
+            var secondVm = new SettingsPageViewModel(store, secondCommands);
+            firstVm.Activate(null);
+            secondVm.Activate(null);
+
+            var firstExternal = 0;
+            var secondExternal = 0;
+            firstVm.ExternalChanged += (_, _) => firstExternal++;
+            secondVm.ExternalChanged += (_, _) => secondExternal++;
+
+            firstVm.NotifyStock = false;
+
+            Assert.False(settings.NotifyStock);
+            Assert.Equal(0, firstExternal);
+            Assert.Equal(1, secondExternal);
+            Assert.False(secondVm.NotifyStock);
+
+            secondVm.GlobalHotkeyEnabled = false;
+
+            Assert.False(settings.GlobalHotkeyEnabled);
+            Assert.Equal(1, firstExternal);
+            Assert.Equal(1, secondExternal);
+            Assert.False(firstVm.GlobalHotkeyEnabled);
+        }
+    }
+
+    [Fact]
+    public void SettingsPageAndPermissionsPage_IgnoreOnlyOwnWrites_InBothDirections()
+    {
+        using var store = NewStore(out var settings, out var dispatcher, out var temp);
+        using (temp)
+        {
+            var settingsVm = new SettingsPageViewModel(store, new FakeAppCommands());
+            var permissionsVm = NewPermissionsVm(store, settings, dispatcher, temp);
+            settingsVm.Activate(null);
+            permissionsVm.Activate(null);
+
+            var settingsExternal = 0;
+            var permissionsExternal = 0;
+            settingsVm.ExternalChanged += (_, _) => settingsExternal++;
+            permissionsVm.ExternalChanged += (_, _) => permissionsExternal++;
+
+            settingsVm.NotifyStock = false;
+
+            Assert.False(settings.NotifyStock);
+            Assert.Equal(0, settingsExternal);
+            Assert.Equal(1, permissionsExternal);
+
+            permissionsVm.NodeModeEnabled = true;
+
+            Assert.True(settings.EnableNodeMode);
+            Assert.Equal(1, settingsExternal);
+            Assert.Equal(1, permissionsExternal);
+        }
+    }
+
+    [Fact]
+    public void PermissionsPageViewModel_ReceivesOneExternalUpdate_PerDistinctAppSurfaceOrigin()
+    {
+        using var store = NewStore(out var settings, out var dispatcher, out var temp);
+        using (temp)
+        {
+            var permissionsVm = NewPermissionsVm(store, settings, dispatcher, temp);
+            permissionsVm.Activate(null);
+
+            var externalChanges = 0;
+            permissionsVm.ExternalChanged += (_, _) => externalChanges++;
+
+            var trayOrigin = store.CreateOrigin();
+            var hubOrigin = store.CreateOrigin();
+            var connectionOrigin = store.CreateOrigin();
+            var appOrigin = store.CreateOrigin();
+
+            store.Update(trayOrigin, edit => edit.EnableNodeMode = true);
+            store.Update(hubOrigin, edit => edit.NodeCameraEnabled = true);
+            store.Update(connectionOrigin, edit => edit.NodeCanvasEnabled = true);
+            store.Update(appOrigin, edit => edit.EnableMcpServer = true);
+
+            Assert.Equal(4, externalChanges);
+            Assert.True(permissionsVm.NodeModeEnabled);
+            Assert.True(permissionsVm.Capabilities.Single(capability => capability.Key == PermissionsCapabilityKey.Camera).IsOn);
+            Assert.True(permissionsVm.Capabilities.Single(capability => capability.Key == PermissionsCapabilityKey.Canvas).IsOn);
+            Assert.True(permissionsVm.McpEnabled);
+        }
+    }
+
+    [Fact]
+    public void AppOwnedDirectWrite_CarriesOrigin_AndUpdatesOtherActiveViewModel()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var firstVm = new SettingsPageViewModel(store, new SelfWritingAppCommands(store));
+            var secondVm = new SettingsPageViewModel(store, new SelfWritingAppCommands(store));
+            firstVm.Activate(null);
+            secondVm.Activate(null);
+
+            var firstExternal = 0;
+            var secondExternal = 0;
+            var events = new List<SettingsChangedEventArgs>();
+            firstVm.ExternalChanged += (_, _) => firstExternal++;
+            secondVm.ExternalChanged += (_, _) => secondExternal++;
+            store.Changed += (_, args) => events.Add(args);
+
+            firstVm.AutoStart = false;
+
+            Assert.False(settings.AutoStart);
+            Assert.Equal(0, firstExternal);
+            Assert.Equal(1, secondExternal);
+            Assert.False(secondVm.AutoStart);
+            Assert.Single(events);
+            Assert.All(events, args => Assert.NotNull(args.Origin));
+        }
+    }
+
+    [Fact]
+    public void Version_IsMonotonic_AcrossMixedWrites()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var versions = new List<long>();
+            var vm = new SettingsPageViewModel(store, new SelfWritingAppCommands(store));
+            vm.Activate(null);
+            store.Changed += (_, args) => versions.Add(args.Version);
+
+            settings.Save();
+            vm.ShowNotifications = false;
+            vm.AutoStart = false;
+
+            Assert.Equal(new long[] { 1, 2, 3 }, versions);
+            Assert.Equal(3, store.Current.Version);
+        }
+    }
+
+    [Fact]
+    public void SettingsPageViewModel_OwnWriteWatermarkRejectsOlderQueuedExternalSnapshot()
+    {
+        using var store = NewStore(out var settings, out var dispatcher, out var temp);
+        using (temp)
+        {
+            var vm = new SettingsPageViewModel(store, new FakeAppCommands());
+            vm.Activate(null);
+
+            dispatcher.HasThreadAccess = false;
+            dispatcher.RunEnqueuedImmediately = false;
+            settings.NotifyBuild = false;
+            settings.Save();
+
+            dispatcher.HasThreadAccess = true;
+            vm.GlobalHotkeyEnabled = false;
+            Assert.Equal(2, store.Current.Version);
+
+            dispatcher.FlushPending();
+
+            Assert.False(settings.GlobalHotkeyEnabled);
+            Assert.False(store.Current.GlobalHotkeyEnabled);
+            Assert.False(vm.GlobalHotkeyEnabled);
+            Assert.False(vm.NotifyBuild);
+        }
+    }
+
+    [Fact]
+    public void PermissionsPageViewModel_OwnWriteWatermarkRejectsOlderQueuedUiApplication()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        using (var viewModelDispatcher = new RecordingUiDispatcher())
+        using (var vm = NewPermissionsVm(store, settings, viewModelDispatcher, temp))
+        {
+            vm.Activate(null);
+
+            viewModelDispatcher.HasThreadAccess = false;
+            viewModelDispatcher.RunEnqueuedImmediately = false;
+            settings.NodeCameraEnabled = true;
+            settings.Save();
+
+            viewModelDispatcher.HasThreadAccess = true;
+            vm.NodeModeEnabled = true;
+            Assert.Equal(2, store.Current.Version);
+
+            viewModelDispatcher.FlushPending();
+
+            Assert.True(settings.EnableNodeMode);
+            Assert.True(store.Current.EnableNodeMode);
+            Assert.True(vm.NodeModeEnabled);
+            Assert.True(vm.Capabilities.Single(
+                capability => capability.Key == PermissionsCapabilityKey.Camera).IsOn);
+        }
+    }
+
+    [Fact]
+    public async Task ConcurrentDisjointUpdates_AreSerialized_AndPreserveBothFields()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var originA = store.CreateOrigin();
+            var originB = store.CreateOrigin();
+            var go = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+            var first = Task.Run(async () =>
+            {
+                await go.Task;
+                store.Update(originA, edit => edit.NotifyHealth = false);
+            });
+
+            var second = Task.Run(async () =>
+            {
+                await go.Task;
+                store.Update(originB, edit => edit.NotifyBuild = false);
+            });
+
+            go.SetResult();
+            await Task.WhenAll(first, second);
+
+            Assert.False(settings.NotifyHealth);
+            Assert.False(settings.NotifyBuild);
+            Assert.Equal(2, store.Current.Version);
+        }
+    }
+
+    [Fact]
+    public void StaleSettingsPageViewModel_Write_DoesNotReplayWholeSnapshot()
+    {
+        using var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var staleVm = new SettingsPageViewModel(store, new FakeAppCommands());
+            staleVm.Activate(null);
+            staleVm.Deactivate();
+
+            var activeVm = new SettingsPageViewModel(store, new FakeAppCommands());
+            activeVm.Activate(null);
+            activeVm.NotifyBuild = false;
+
+            staleVm.GlobalHotkeyEnabled = false;
+
+            Assert.False(settings.NotifyBuild);
+            Assert.False(settings.GlobalHotkeyEnabled);
+        }
+    }
+
+    [Fact]
+    public void Dispose_Unsubscribes_FromSettingsManager()
+    {
+        var store = NewStore(out var settings, out _, out var temp);
+        using (temp)
+        {
+            var changed = 0;
+            store.Changed += (_, _) => changed++;
+
+            store.Dispose();
+            settings.Save();
+
+            Assert.Equal(0, changed);
+        }
+    }
+
+    [Fact]
+    public void SettingsStoreSource_DoesNotUseThreadStaticSuppression()
+    {
+        var source = File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Presentation",
+            "SettingsStore.cs"));
+
+        Assert.DoesNotContain("[ThreadStatic]", source);
+        Assert.DoesNotContain("AsyncLocal", source);
+    }
+
+    [Fact]
+    public void PermissionsPageViewModel_UsesSharedSettingsStoreInsteadOfConcreteManager()
+    {
+        var ctor = Assert.Single(
+            typeof(PermissionsPageViewModel).GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
+        var parameterTypes = ctor.GetParameters().Select(parameter => parameter.ParameterType).ToArray();
+
+        Assert.Contains(typeof(ISettingsStore), parameterTypes);
+        Assert.Contains(typeof(IExecApprovalsPresentationStore), parameterTypes);
+        Assert.Contains(typeof(IPermissionsPageRuntimeSource), parameterTypes);
+        Assert.DoesNotContain(typeof(SettingsManager), parameterTypes);
+    }
+}
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs
index 716f5b355..12d8b49b0 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsStoreTests.cs
@@ -5,8 +5,7 @@ namespace OpenClaw.Tray.Tests.Presentation;
 
 /// <summary>
 /// Behavior of the settings facade over the real settings manager: snapshot reads, batched
-/// single save, self-originated echo suppression, external-change republishing, and UI-thread
-/// affinity for the <see cref="ISettingsStore.Changed"/> notification.
+/// single save, typed origin-aware notifications, UI-thread affinity, and disposal.
 /// </summary>
 public sealed class SettingsStoreTests
 {
@@ -19,9 +18,9 @@ private static SettingsStore NewStore(out SettingsManager settings, out Recordin
     }
 
     [Fact]
-    public void Current_ReflectsUnderlyingSettings()
+    public void Current_ReflectsUnderlyingSettings_AndVersion()
     {
-        var store = NewStore(out var settings, out _, out var temp);
+        using var store = NewStore(out var settings, out _, out var temp);
         using (temp)
         {
             settings.GlobalHotkeyEnabled = true;
@@ -31,19 +30,20 @@ public void Current_ReflectsUnderlyingSettings()
 
             Assert.True(snapshot.GlobalHotkeyEnabled);
             Assert.Equal("Subtle", snapshot.NotificationSound);
+            Assert.Equal(0, snapshot.Version);
         }
     }
 
     [Fact]
     public void Update_MutatesAndPersists_Once()
     {
-        var store = NewStore(out var settings, out _, out var temp);
+        using var store = NewStore(out var settings, out _, out var temp);
         using (temp)
         {
             var saves = 0;
             settings.Saved += (_, _) => saves++;
 
-            store.Update(e =>
+            store.Update(store.CreateOrigin(), e =>
             {
                 e.GlobalHotkeyEnabled = true;
                 e.NotifyHealth = true;
@@ -51,87 +51,114 @@ public void Update_MutatesAndPersists_Once()
 
             Assert.True(settings.GlobalHotkeyEnabled);
             Assert.True(settings.NotifyHealth);
-            Assert.Equal(1, saves); // batched: one save for both edits
+            Assert.Equal(1, saves);
+        }
+    }
+
+    [Fact]
+    public void Update_RaisesChangedOnce_WithOriginAndUpdatedSnapshot()
+    {
+        using var store = NewStore(out _, out _, out var temp);
+        using (temp)
+        {
+            var origin = store.CreateOrigin();
+            SettingsChangedEventArgs? args = null;
+            store.Changed += (_, changed) => args = changed;
+
+            store.Update(origin, e => e.GlobalHotkeyEnabled = true);
+
+            Assert.NotNull(args);
+            Assert.Same(origin, args!.Origin);
+            Assert.True(args.Snapshot.GlobalHotkeyEnabled);
+            Assert.Equal(1, args.Version);
         }
     }
 
     [Fact]
     public void Update_DoesNotEchoChangedToSelf()
     {
-        var store = NewStore(out _, out _, out var temp);
+        using var store = NewStore(out _, out _, out var temp);
         using (temp)
         {
-            var changed = 0;
-            store.Changed += (_, _) => changed++;
+            var origin = store.CreateOrigin();
+            var visibleChanges = 0;
+            store.Changed += (_, args) =>
+            {
+                if (!ReferenceEquals(args.Origin, origin))
+                {
+                    visibleChanges++;
+                }
+            };
 
-            store.Update(e => e.GlobalHotkeyEnabled = true);
+            store.Update(origin, e => e.GlobalHotkeyEnabled = true);
 
-            Assert.Equal(0, changed); // self-originated save is suppressed
+            Assert.Equal(0, visibleChanges);
         }
     }
 
     [Fact]
-    public void ExternalSave_RaisesChanged()
+    public void ExternalSave_RaisesChangedWithNullOrigin()
     {
-        var store = NewStore(out var settings, out _, out var temp);
+        using var store = NewStore(out var settings, out _, out var temp);
         using (temp)
         {
-            var changed = 0;
-            store.Changed += (_, _) => changed++;
+            SettingsChangedEventArgs? args = null;
+            store.Changed += (_, changed) => args = changed;
 
-            // A save that did not originate from this store's Update (e.g. another surface).
             settings.GlobalHotkeyEnabled = true;
             settings.Save();
 
-            Assert.Equal(1, changed);
+            Assert.NotNull(args);
+            Assert.Null(args!.Origin);
+            Assert.True(args.Snapshot.GlobalHotkeyEnabled);
+            Assert.Equal(1, args.Version);
         }
     }
 
     [Fact]
     public void ExternalSave_OffUiThread_IsMarshaledThroughDispatcher()
     {
-        var store = NewStore(out var settings, out var dispatcher, out var temp);
+        using var store = NewStore(out var settings, out var dispatcher, out var temp);
         using (temp)
         {
             dispatcher.HasThreadAccess = false;
             dispatcher.RunEnqueuedImmediately = false;
-            var changed = 0;
-            store.Changed += (_, _) => changed++;
+            SettingsChangedEventArgs? args = null;
+            store.Changed += (_, changed) => args = changed;
 
             settings.Save();
 
-            Assert.Equal(0, changed);          // not raised inline off-thread
+            Assert.Null(args);
             Assert.Equal(1, dispatcher.EnqueuedCount);
 
             dispatcher.FlushPending();
-            Assert.Equal(1, changed);          // raised once marshaled to the UI thread
+            Assert.NotNull(args);
+            Assert.Equal(1, args!.Version);
         }
     }
 
     [Fact]
-    public void BeginSelfWrite_SuppressesChanged_ForDirectSave()
+    public void Update_ThrowingEdit_DoesNotLeakOrigin_ToLaterExternalSave()
     {
-        var store = NewStore(out var settings, out _, out var temp);
+        using var store = NewStore(out var settings, out _, out var temp);
         using (temp)
         {
-            var changed = 0;
-            store.Changed += (_, _) => changed++;
+            SettingsChangedEventArgs? args = null;
+            store.Changed += (_, changed) => args = changed;
 
-            using (store.BeginSelfWrite())
-            {
-                settings.GlobalHotkeyEnabled = true;
-                settings.Save();
-            }
+            Assert.Throws<InvalidOperationException>(() =>
+                store.Update(store.CreateOrigin(), _ => throw new InvalidOperationException("boom")));
 
-            Assert.Equal(0, changed); // save inside a self-write scope is suppressed like Update
+            settings.Save();
 
-            settings.Save(); // outside the scope it is external again
-            Assert.Equal(1, changed);
+            Assert.NotNull(args);
+            Assert.Null(args!.Origin);
+            Assert.Equal(1, args.Version);
         }
     }
 
     [Fact]
-    public void Update_ThrowingEdit_ResetsSelfWriteDepth_SoLaterExternalSaveRepublishes()
+    public void Dispose_Unsubscribes_FromSettingsManager()
     {
         var store = NewStore(out var settings, out _, out var temp);
         using (temp)
@@ -139,13 +166,10 @@ public void Update_ThrowingEdit_ResetsSelfWriteDepth_SoLaterExternalSaveRepublis
             var changed = 0;
             store.Changed += (_, _) => changed++;
 
-            Assert.Throws<InvalidOperationException>(() =>
-                store.Update(_ => throw new InvalidOperationException("boom")));
-
-            // The scope's Dispose ran in the finally path, so the depth is not stuck above zero:
-            // a subsequent external save still republishes Changed exactly once.
+            store.Dispose();
             settings.Save();
-            Assert.Equal(1, changed);
+
+            Assert.Equal(0, changed);
         }
     }
 }
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SpeechSetupReadinessTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SpeechSetupReadinessTests.cs
new file mode 100644
index 000000000..8ba5d49ff
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/Presentation/SpeechSetupReadinessTests.cs
@@ -0,0 +1,50 @@
+using OpenClaw.Shared;
+using OpenClaw.Shared.Audio;
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests.Presentation;
+
+public sealed class SpeechSetupReadinessTests
+{
+    [Fact]
+    public void NodeSttEnabled_VoiceServiceNull_ModelPresent_DoesNotRequireSetup()
+    {
+        using var temp = new TempDir();
+        var settings = new SettingsManager(temp.Path)
+        {
+            NodeSttEnabled = true,
+            SttModelName = "base",
+        };
+        var models = new WhisperModelManager(temp.Path, NullLogger.Instance);
+        File.WriteAllBytes(models.GetModelPath("base"), [0]);
+
+        var needsWarning = settings.NodeSttEnabled
+            && SpeechSetupReadiness.IsConfiguredSttModelSetupRequired(
+                settings,
+                temp.Path,
+                NullLogger.Instance);
+
+        Assert.False(needsWarning);
+    }
+
+    [Theory]
+    [InlineData("base")]
+    [InlineData("unknown")]
+    public void NodeSttEnabled_VoiceServiceNull_MissingOrUnknownModel_RequiresSetup(string modelName)
+    {
+        using var temp = new TempDir();
+        var settings = new SettingsManager(temp.Path)
+        {
+            NodeSttEnabled = true,
+            SttModelName = modelName,
+        };
+
+        var needsWarning = settings.NodeSttEnabled
+            && SpeechSetupReadiness.IsConfiguredSttModelSetupRequired(
+                settings,
+                temp.Path,
+                NullLogger.Instance);
+
+        Assert.True(needsWarning);
+    }
+}
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/UiDispatcherContractTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/UiDispatcherContractTests.cs
index 015137266..0c6608e2c 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/UiDispatcherContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/UiDispatcherContractTests.cs
@@ -1,13 +1,13 @@
 using Microsoft.Extensions.DependencyInjection;
+using OpenClaw.Shared;
+using OpenClaw.Shared.ExecApprovals;
 using OpenClawTray.Presentation;
 using OpenClawTray.Services;
 
 namespace OpenClaw.Tray.Tests.Presentation;
 
 /// <summary>
-/// Behavioral guard for the UI-thread dispatcher abstraction. Confirms view models
-/// depend on <see cref="IUiDispatcher"/> via DI (not a concrete dispatcher queue) and
-/// locks the basic dispatcher contract.
+/// Behavioral guard for the UI-thread dispatcher abstraction.
 /// </summary>
 public sealed class UiDispatcherContractTests
 {
@@ -16,10 +16,15 @@ public void PageViewModel_ReceivesRegisteredDispatcher()
     {
         using var temp = new TempDir();
         var dispatcher = new RecordingUiDispatcher();
+        var execApprovalsStore = new ExecApprovalsStore(temp.Path, NullLogger.Instance);
 
         var services = new ServiceCollection();
         services.AddOpenClawTrayCore(new AppServiceContext(
-            dispatcher, new FakeAppCommands(), new SettingsManager(temp.Path)));
+            dispatcher,
+            new FakeAppCommands(),
+            new SettingsManager(temp.Path),
+            execApprovalsStore,
+            new FakePermissionsPageRuntimeHost()));
         using var provider = services.BuildServiceProvider(new ServiceProviderOptions
         {
             ValidateScopes = true,
@@ -27,13 +32,8 @@ public void PageViewModel_ReceivesRegisteredDispatcher()
         });
 
         using var scope = provider.CreateScope();
-        var settingsVm = scope.ServiceProvider.GetRequiredService<SettingsPageViewModel>();
         var permissionsVm = scope.ServiceProvider.GetRequiredService<PermissionsPageViewModel>();
 
-        // Both page view models resolve from the registered core, so their IUiDispatcher
-        // dependency is satisfied from DI (not a concrete dispatcher queue). The placeholder
-        // permissions view model still exposes the injected instance for a strong same-reference check.
-        Assert.NotNull(settingsVm);
         Assert.Same(dispatcher, permissionsVm.Dispatcher);
     }
 
diff --git a/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs b/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs
index 9c87ea97c..0bedc51c4 100644
--- a/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs
@@ -4,6 +4,39 @@ namespace OpenClaw.Tray.Tests.Services;
 
 public sealed class McpRuntimeStatePolicyTests
 {
+    [Theory]
+    [InlineData(false)]
+    [InlineData(true)]
+    public void PlanCapabilityEnable_McpOnlyRestartAlwaysRebuildsFromCurrentSettings(
+        bool hasCachedCapabilities)
+    {
+        var plan = McpRuntimeStatePolicy.PlanCapabilityEnable(
+            hasGatewayClient: false,
+            hasCapabilities: hasCachedCapabilities);
+
+        Assert.Equal(McpCapabilityEnablePlan.RebuildFromCurrentSettings, plan);
+    }
+
+    [Fact]
+    public void PlanCapabilityEnable_GatewayClientWithCompleteSetReusesCurrentRegistration()
+    {
+        var plan = McpRuntimeStatePolicy.PlanCapabilityEnable(
+            hasGatewayClient: true,
+            hasCapabilities: true);
+
+        Assert.Equal(McpCapabilityEnablePlan.ReuseCurrent, plan);
+    }
+
+    [Fact]
+    public void PlanCapabilityEnable_GatewayClientWithEmptySetRebuilds()
+    {
+        var plan = McpRuntimeStatePolicy.PlanCapabilityEnable(
+            hasGatewayClient: true,
+            hasCapabilities: false);
+
+        Assert.Equal(McpCapabilityEnablePlan.RebuildFromCurrentSettings, plan);
+    }
+
     [Fact]
     public void PlanStartupNotification_WhenDisabled_Dismisses()
     {
diff --git a/tests/OpenClaw.Tray.Tests/SpeechInputContractTests.cs b/tests/OpenClaw.Tray.Tests/SpeechInputContractTests.cs
index 2eafce736..0d3688d0b 100644
--- a/tests/OpenClaw.Tray.Tests/SpeechInputContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/SpeechInputContractTests.cs
@@ -86,7 +86,9 @@ public void ChatVoiceDialogs_RouteDisabledTtsCapabilityToPermissions_AndPreserve
         Assert.Contains("ShowHub(\"permissions\")", chatWindow);
         Assert.DoesNotContain("ChatVoiceDialog_TtsSetupRequired", chatWindow);
 
-        Assert.Contains("SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired", permissionsPage);
+        Assert.Contains("VoiceSettingsHelpPanel.Visibility", permissionsPage);
+        Assert.DoesNotContain("SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired", permissionsPage);
+        Assert.Contains("SpeechSetupReadiness.IsConfiguredTtsProviderSetupRequired", Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"));
         Assert.Contains("SpeechSetupReadiness.IsAutomaticChatTtsEnabled", Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"));
         Assert.Contains("IsAutomaticChatTtsEnabled", Read("src", "OpenClaw.Tray.WinUI", "Services", "SpeechSetupReadiness.cs"));
         Assert.Contains("return settings?.NodeTtsEnabled == true;", Read("src", "OpenClaw.Tray.WinUI", "Services", "SpeechSetupReadiness.cs"));
