diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs index 0e2b05987..f9fefdd79 100644 --- a/src/OpenClaw.Cli/Program.cs +++ b/src/OpenClaw.Cli/Program.cs @@ -4,10 +4,10 @@ internal sealed class CliOptions { - public string SettingsPath { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OpenClawTray", - "settings.json"); + public string SettingsPath { get; set; } = ""; + public bool SettingsPathExplicit { get; set; } + public string? Identity { get; set; } + public string IdentityDataPath { get; set; } = ""; public string? GatewayUrlOverride { get; set; } public string? TokenOverride { get; set; } @@ -33,6 +33,7 @@ private static async Task Main(string[] args) try { options = ParseArgs(args); + ApplyIdentityDefaults(options, Environment.GetEnvironmentVariable); } catch (Exception ex) { @@ -64,7 +65,11 @@ private static async Task Main(string[] args) } IOpenClawLogger logger = options.Verbose ? new ConsoleLogger() : NullLogger.Instance; - using var client = new OpenClawGatewayClient(gatewayUrl, token, logger); + using var client = new OpenClawGatewayClient( + gatewayUrl, + token, + logger, + identityPath: options.IdentityDataPath); var lastStatus = ConnectionStatus.Disconnected; var connectedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -218,6 +223,10 @@ private static CliOptions ParseArgs(string[] args) { case "--settings": options.SettingsPath = RequireValue(args, ref i, arg); + options.SettingsPathExplicit = true; + break; + case "--identity": + options.Identity = OpenClawAppIdentity.NormalizeIdentity(RequireValue(args, ref i, arg)); break; case "--url": options.GatewayUrlOverride = RequireValue(args, ref i, arg); @@ -251,6 +260,16 @@ private static CliOptions ParseArgs(string[] args) return options; } + private static void ApplyIdentityDefaults(CliOptions options, Func envLookup) + { + options.Identity = OpenClawAppIdentity.ResolveIdentity(envLookup, options.Identity); + options.IdentityDataPath = OpenClawAppIdentity.ResolveRoamingDataDirectory(envLookup, options.Identity); + if (!options.SettingsPathExplicit) + { + options.SettingsPath = OpenClawAppIdentity.ResolveSettingsPath(envLookup, options.Identity); + } + } + private static string RequireValue(string[] args, ref int index, string name) { if (index + 1 >= args.Length) @@ -282,7 +301,8 @@ private static void PrintUsage() Console.WriteLine(" dotnet run --project src/OpenClaw.Cli -- [options]"); Console.WriteLine(); Console.WriteLine("Options:"); - Console.WriteLine(" --settings Settings file (default: %APPDATA%\\OpenClawTray\\settings.json)"); + Console.WriteLine(" --settings Settings file (default: selected identity profile)"); + Console.WriteLine(" --identity Select tray profile (default: %OPENCLAW_APP_IDENTITY% or release)"); Console.WriteLine(" --url Override gateway URL"); Console.WriteLine(" --token Override token"); Console.WriteLine(" --message Message to send"); diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index b68179a36..98e415d6b 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -710,16 +710,17 @@ private async Task ValidateSharedTokenBeforeReplacementAsync( private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, long gen) { - // Check client's pairing status directly — set synchronously before this handler runs - var isPairingPending = _activeLifecycle?.DataClient?.IsPairingRequired == true; - if (isPairingPending && status is ConnectionStatus.Disconnected or ConnectionStatus.Error) - return; - await _transitionSemaphore.WaitAsync(); try { if (Interlocked.Read(ref _generation) != gen) return; + // Check client's pairing status while holding the transition lock so + // a completed pairing cannot race with a stale disconnect/error event. + var isPairingPending = _activeLifecycle?.DataClient?.IsPairingRequired == true; + if (isPairingPending && status is ConnectionStatus.Disconnected or ConnectionStatus.Error) + return; + switch (status) { case ConnectionStatus.Connected: diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 378af3f28..f4675e7ae 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -3479,7 +3479,7 @@ public override Task ExecuteAsync(SetupContext ctx, CancellationToke psi.ArgumentList.Add("sleep"); psi.ArgumentList.Add("infinity"); - var proc = System.Diagnostics.Process.Start(psi); + using var proc = System.Diagnostics.Process.Start(psi); if (proc == null) { ctx.Logger.Warn("Failed to start keepalive process — tray will start its own"); diff --git a/src/OpenClaw.Shared/OpenClawAppIdentity.cs b/src/OpenClaw.Shared/OpenClawAppIdentity.cs new file mode 100644 index 000000000..99c7a21b2 --- /dev/null +++ b/src/OpenClaw.Shared/OpenClawAppIdentity.cs @@ -0,0 +1,76 @@ +namespace OpenClaw.Shared; + +/// +/// Shared profile/path rules for standalone tools that need to find the same +/// release or dev profile used by the tray. +/// +public static class OpenClawAppIdentity +{ + public const string ReleaseIdentity = "release"; + public const string DevIdentity = "dev"; + public const string IdentityEnvironmentVariable = "OPENCLAW_APP_IDENTITY"; + public const string DataDirectoryOverrideEnvironmentVariable = "OPENCLAW_TRAY_DATA_DIR"; + public const string AppDataRootEnvironmentVariable = "OPENCLAW_TRAY_APPDATA_DIR"; + public const string ReleaseDataDirectoryName = "OpenClawTray"; + public const string DevDataDirectoryName = "OpenClawTray-Dev"; + + public static string NormalizeIdentity(string? identity) + { + if (string.IsNullOrWhiteSpace(identity)) + return ReleaseIdentity; + + if (string.Equals(identity, ReleaseIdentity, StringComparison.OrdinalIgnoreCase)) + return ReleaseIdentity; + + if (string.Equals(identity, DevIdentity, StringComparison.OrdinalIgnoreCase)) + return DevIdentity; + + throw new ArgumentException( + $"App identity must be '{ReleaseIdentity}' or '{DevIdentity}' (got '{identity}').", + nameof(identity)); + } + + public static string ResolveIdentity(Func envLookup, string? explicitIdentity = null) + { + ArgumentNullException.ThrowIfNull(envLookup); + + return NormalizeIdentity( + !string.IsNullOrWhiteSpace(explicitIdentity) + ? explicitIdentity + : envLookup(IdentityEnvironmentVariable)); + } + + public static string GetDataDirectoryName(string? identity) => + NormalizeIdentity(identity) == DevIdentity + ? DevDataDirectoryName + : ReleaseDataDirectoryName; + + public static string ResolveRoamingDataDirectory( + Func envLookup, + string? explicitIdentity = null) + { + ArgumentNullException.ThrowIfNull(envLookup); + + var dataDirOverride = envLookup(DataDirectoryOverrideEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(dataDirOverride)) + return dataDirOverride!; + + var root = envLookup(AppDataRootEnvironmentVariable); + if (string.IsNullOrWhiteSpace(root)) + root = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + + return Path.Combine( + root!, + GetDataDirectoryName(ResolveIdentity(envLookup, explicitIdentity))); + } + + public static string ResolveSettingsPath( + Func envLookup, + string? explicitIdentity = null) => + Path.Combine(ResolveRoamingDataDirectory(envLookup, explicitIdentity), "settings.json"); + + public static string ResolveMcpTokenPath( + Func envLookup, + string? explicitIdentity = null) => + Path.Combine(ResolveRoamingDataDirectory(envLookup, explicitIdentity), "mcp-token.txt"); +} diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 1d5c139e6..91a764368 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -100,10 +100,10 @@ public partial class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatew private readonly bool _ignoreStoredDeviceToken; /// True when the gateway reported "pairing required" for this device. - public bool IsPairingRequired => _pairingRequiredAwaitingApproval; + public bool IsPairingRequired => Volatile.Read(ref _pairingRequiredAwaitingApproval); /// Safe requestId returned in structured pairing-required details, when present. - public string? PairingRequiredRequestId => _pairingRequiredRequestId; + public string? PairingRequiredRequestId => Volatile.Read(ref _pairingRequiredRequestId); /// True when the device signature was rejected in all supported modes. public bool IsAuthFailed => _authFailed; @@ -248,10 +248,8 @@ public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? l _bootstrapPairAsNode = bootstrapPairAsNode; _ignoreStoredDeviceToken = ignoreStoredDeviceToken; _currentGatewayUrl = gatewayUrl; - var dataPath = identityPath ?? Path.Combine( - Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") - ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OpenClawTray"); + var dataPath = identityPath ?? OpenClawAppIdentity.ResolveRoamingDataDirectory( + Environment.GetEnvironmentVariable); _deviceIdentity = new DeviceIdentity(dataPath, _logger); _deviceIdentity.Initialize(); @@ -1759,8 +1757,8 @@ private void HandleResponse(JsonElement root) if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") { _logger.Info($"[HANDSHAKE] Received hello-ok!"); - _pairingRequiredAwaitingApproval = false; - _pairingRequiredRequestId = null; + Volatile.Write(ref _pairingRequiredAwaitingApproval, false); + Volatile.Write(ref _pairingRequiredRequestId, null); _authFailed = false; ResetReconnectAttempts(); _operatorDeviceId = TryGetHandshakeDeviceId(payload); @@ -2067,8 +2065,8 @@ private void HandleRequestError(string? method, JsonElement root) if (method == "connect" && (pairingDetails.IsPairingRequired || message.Contains("pairing required", StringComparison.OrdinalIgnoreCase))) { - _pairingRequiredAwaitingApproval = true; - _pairingRequiredRequestId = pairingDetails.RequestId; + Volatile.Write(ref _pairingRequiredRequestId, pairingDetails.RequestId); + Volatile.Write(ref _pairingRequiredAwaitingApproval, true); _logger.Warn($"[HANDSHAKE] Pairing required (requestId={pairingDetails.RequestId}). Waiting for approval."); PairingRequired?.Invoke(this, pairingDetails.RequestId); return; diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml index 943cf3979..9b343775c 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -329,6 +329,8 @@ Click="OnRemoveRule" Tag="{Binding Index}" Background="Transparent" BorderThickness="0" Padding="6" MinWidth="0" + AutomationProperties.Name="{Binding RemoveRuleAutomationName}" + AutomationProperties.AutomationId="{Binding RemoveRuleAutomationId}" ToolTipService.ToolTip="Remove rule"> diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs index 1309f89b8..7f9f02741 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs @@ -711,6 +711,8 @@ private void RefreshPolicyRulesList() r.Pattern, Action = DisplayExecPolicyAction(r.Action), r.Index, + RemoveRuleAutomationName = $"Remove rule {r.Pattern}", + RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{r.Index}", ActionBrush = r.Action == "allow" ? allowBrush : r.Action == "prompt" ? askBrush : denyBrush diff --git a/src/OpenClaw.WinNode.Cli/Program.cs b/src/OpenClaw.WinNode.Cli/Program.cs index bdd829d05..2e2231e29 100644 --- a/src/OpenClaw.WinNode.Cli/Program.cs +++ b/src/OpenClaw.WinNode.Cli/Program.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using OpenClaw.Shared; using OpenClaw.Shared.Mcp; namespace OpenClaw.WinNode.Cli; @@ -19,6 +20,7 @@ internal sealed class WinNodeOptions public string? McpUrlOverride { get; set; } public int? McpPortOverride { get; set; } public string? McpTokenOverride { get; set; } + public string? Identity { get; set; } public bool Verbose { get; set; } } @@ -151,6 +153,16 @@ public static async Task RunAsync( return 2; } + try + { + options.Identity = OpenClawAppIdentity.ResolveIdentity(envLookup, options.Identity); + } + catch (ArgumentException ex) + { + stderr.WriteLine($"Argument error: {ex.Message}"); + return 2; + } + var token = ResolveAuthToken(options, envLookup, stderr); if (token.Source == "error") { @@ -533,7 +545,9 @@ private static int ResolveEnvPort(Func envLookup, bool verbose, /// per-tool secret env-var convention — same shape as GITHUB_TOKEN, /// ANTHROPIC_API_KEY, NUGET_API_KEY. /// The on-disk token file the tray writes when MCP is enabled — - /// %APPDATA%\OpenClawTray\mcp-token.txt by default, or + /// %APPDATA%\OpenClawTray\mcp-token.txt by default, + /// %APPDATA%\OpenClawTray-Dev\mcp-token.txt when + /// --identity dev or OPENCLAW_APP_IDENTITY=dev is set, or /// $OPENCLAW_TRAY_DATA_DIR\mcp-token.txt when the tray was launched /// with that sandbox override (the integration test fixture uses it). /// @@ -559,7 +573,7 @@ internal static AuthTokenResult ResolveAuthToken( return new AuthTokenResult(envToken, "OPENCLAW_MCP_TOKEN"); } - var path = ResolveTokenPath(envLookup); + var path = ResolveTokenPath(envLookup, options.Identity); // F-08: resolve to canonical form and require the result still live // under the requested directory tree. Defeats a same-user attacker @@ -697,7 +711,7 @@ private static bool PathStartsWith(string candidate, string prefix) || normCandidate.StartsWith(normPrefix + Path.DirectorySeparatorChar, cmp); } - internal static string ResolveTokenPath(Func envLookup) + internal static string ResolveTokenPath(Func envLookup, string? identity = null) { // Mirror SettingsManager.SettingsDirectoryPath: when the tray was // launched with OPENCLAW_TRAY_DATA_DIR, settings (including the token @@ -705,13 +719,7 @@ internal static string ResolveTokenPath(Func envLookup) // so a CLI invoked in the same shell as a sandboxed tray Just Works, // and the integration test fixture can redirect both the producer // (tray) and the consumer (CLI) with one env var. - var dataDirOverride = envLookup("OPENCLAW_TRAY_DATA_DIR"); - var dir = !string.IsNullOrWhiteSpace(dataDirOverride) - ? dataDirOverride! - : Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "OpenClawTray"); - return Path.Combine(dir, "mcp-token.txt"); + return OpenClawAppIdentity.ResolveMcpTokenPath(envLookup, identity); } /// @@ -774,6 +782,9 @@ internal static WinNodeOptions ParseArgs(string[] args) case "--mcp-token": options.McpTokenOverride = RequireValue(args, ref i, arg); break; + case "--identity": + options.Identity = OpenClawAppIdentity.NormalizeIdentity(RequireValue(args, ref i, arg)); + break; case "--verbose": options.Verbose = true; break; @@ -830,6 +841,8 @@ internal static void PrintUsage(TextWriter stdout) stdout.WriteLine(" --mcp-token Bearer token (testing/explicit overrides only - visible to"); stdout.WriteLine(" other processes via the OS process listing). Prefer"); stdout.WriteLine(" $OPENCLAW_MCP_TOKEN or %APPDATA%\\OpenClawTray\\mcp-token.txt"); + stdout.WriteLine(" --identity Select tray profile for default token lookup"); + stdout.WriteLine(" (default: $OPENCLAW_APP_IDENTITY or release)"); stdout.WriteLine(" --verbose Print endpoint + ignored flags to stderr"); stdout.WriteLine(" --help, -h Show this help"); stdout.WriteLine(); diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index bf61068cd..888def3eb 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -23,8 +23,8 @@ argument shape, and the A2UI v0.8 JSONL grammar. It is shipped alongside ## Invocation shape ``` -winnode --command [--params ''] [--invoke-timeout ] -winnode --list-tools [--mcp-url |--mcp-port ] +winnode --command [--params ''] [--invoke-timeout ] [--identity release|dev] +winnode --list-tools [--mcp-url |--mcp-port ] [--identity release|dev] ``` - `--command` (required) — node command (e.g. `system.which`, `canvas.a2ui.push`). @@ -50,9 +50,14 @@ winnode --list-tools [--mcp-url |--mcp-port ] listing** (`Get-CimInstance Win32_Process | Select CommandLine`, Process Explorer, etc.). The CLI emits a stderr warning when this flag is used. **Prefer `OPENCLAW_MCP_TOKEN` (env var) or the on-disk - `%APPDATA%\OpenClawTray\mcp-token.txt`** which the tray writes when MCP is - enabled. Both `OPENCLAW_MCP_TOKEN` and the on-disk file should themselves be - treated as sensitive operational secrets. + `%APPDATA%\OpenClawTray\mcp-token.txt`** which the release tray writes when + MCP is enabled. Both `OPENCLAW_MCP_TOKEN` and the on-disk file should + themselves be treated as sensitive operational secrets. +- `--identity release|dev` — selects which tray profile supplies the default + on-disk MCP token. Defaults to `OPENCLAW_APP_IDENTITY`, then `release`. + Use `--identity dev` for a side-by-side dev tray; its default token path is + `%APPDATA%\OpenClawTray-Dev\mcp-token.txt`. `OPENCLAW_TRAY_DATA_DIR` still + wins for isolated runs and points directly at the data folder. - `--verbose` — log endpoint + ignored flags to stderr. Without `--verbose`, HTTP error bodies are emitted only as the first line; with `--verbose`, the full body is shown (after sanitization + token-shape redaction). diff --git a/tests/OpenClaw.Shared.Tests/OpenClawAppIdentityTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawAppIdentityTests.cs new file mode 100644 index 000000000..a3f5f3eb6 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/OpenClawAppIdentityTests.cs @@ -0,0 +1,86 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public sealed class OpenClawAppIdentityTests +{ + [Fact] + public void ResolveRoamingDataDirectory_DefaultsToReleaseProfile() + { + var root = Path.Combine(Path.GetTempPath(), "openclaw-appdata"); + var path = OpenClawAppIdentity.ResolveRoamingDataDirectory( + key => key == OpenClawAppIdentity.AppDataRootEnvironmentVariable ? root : null); + + Assert.Equal(Path.Combine(root, "OpenClawTray"), path); + } + + [Fact] + public void ResolveRoamingDataDirectory_UsesDevProfileFromEnvironment() + { + var root = Path.Combine(Path.GetTempPath(), "openclaw-appdata"); + var path = OpenClawAppIdentity.ResolveRoamingDataDirectory( + key => key switch + { + OpenClawAppIdentity.AppDataRootEnvironmentVariable => root, + OpenClawAppIdentity.IdentityEnvironmentVariable => OpenClawAppIdentity.DevIdentity, + _ => null + }); + + Assert.Equal(Path.Combine(root, "OpenClawTray-Dev"), path); + } + + [Fact] + public void ResolveRoamingDataDirectory_ExplicitIdentityWinsOverEnvironment() + { + var root = Path.Combine(Path.GetTempPath(), "openclaw-appdata"); + var path = OpenClawAppIdentity.ResolveRoamingDataDirectory( + key => key switch + { + OpenClawAppIdentity.AppDataRootEnvironmentVariable => root, + OpenClawAppIdentity.IdentityEnvironmentVariable => OpenClawAppIdentity.DevIdentity, + _ => null + }, + explicitIdentity: OpenClawAppIdentity.ReleaseIdentity); + + Assert.Equal(Path.Combine(root, "OpenClawTray"), path); + } + + [Fact] + public void ResolveRoamingDataDirectory_DataDirOverrideWinsOverIdentity() + { + var direct = Path.Combine(Path.GetTempPath(), "openclaw-direct-data"); + var path = OpenClawAppIdentity.ResolveRoamingDataDirectory( + key => key switch + { + OpenClawAppIdentity.DataDirectoryOverrideEnvironmentVariable => direct, + OpenClawAppIdentity.IdentityEnvironmentVariable => OpenClawAppIdentity.DevIdentity, + _ => null + }); + + Assert.Equal(direct, path); + } + + [Fact] + public void ResolveSettingsAndTokenPaths_UseSelectedProfile() + { + var root = Path.Combine(Path.GetTempPath(), "openclaw-appdata"); + Func env = key => + key == OpenClawAppIdentity.AppDataRootEnvironmentVariable ? root : null; + + Assert.Equal( + Path.Combine(root, "OpenClawTray-Dev", "settings.json"), + OpenClawAppIdentity.ResolveSettingsPath(env, OpenClawAppIdentity.DevIdentity)); + Assert.Equal( + Path.Combine(root, "OpenClawTray-Dev", "mcp-token.txt"), + OpenClawAppIdentity.ResolveMcpTokenPath(env, OpenClawAppIdentity.DevIdentity)); + } + + [Fact] + public void NormalizeIdentity_RejectsUnknownIdentity() + { + var ex = Assert.Throws(() => OpenClawAppIdentity.NormalizeIdentity("staging")); + + Assert.Contains("release", ex.Message); + Assert.Contains("dev", ex.Message); + } +} diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 870abb364..8d1947eb8 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -305,6 +305,19 @@ public void PermissionsPage_ExecPolicy_UsesAppDataDirectory() Assert.DoesNotContain("SettingsManager.SettingsDirectoryPath, \"exec-policy.json\"", source); } + [Fact] + public void PermissionsPage_ExecPolicyRemoveButtons_HaveAccessibleNames() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml")); + var codeBehind = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs")); + + 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); + } + [Fact] public void Shutdown_Order_PreservesAwaitedTeardownBeforeExit() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs index 74ef7e8ae..cac295b7c 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionRegressionSourceTests.cs @@ -79,9 +79,75 @@ public void NodeCapabilityPills_ExposeStateThroughReadableTextPeer() Assert.DoesNotContain("AutomationProperties.SetName(pill", pageSource); } + [Fact] + public void PairingRequiredDisconnectGuard_RunsInsideTransitionSemaphore() + { + var managerSource = ReadSource("src", "OpenClaw.Connection", "GatewayConnectionManager.cs"); + var methodStart = managerSource.IndexOf("private async Task HandleOperatorStatusChangedAsync", StringComparison.Ordinal); + var waitIndex = managerSource.IndexOf("await _transitionSemaphore.WaitAsync();", methodStart, StringComparison.Ordinal); + var pairingIndex = managerSource.IndexOf("var isPairingPending", methodStart, StringComparison.Ordinal); + + Assert.True(methodStart >= 0); + Assert.True(waitIndex > methodStart); + Assert.True(pairingIndex > waitIndex); + } + + [Fact] + public void GatewayClient_PairingRequiredFlag_UsesVolatileAccess() + { + var source = ReadSource("src", "OpenClaw.Shared", "OpenClawGatewayClient.cs"); + + Assert.Contains("public bool IsPairingRequired => Volatile.Read(ref _pairingRequiredAwaitingApproval);", source); + Assert.Contains("public string? PairingRequiredRequestId => Volatile.Read(ref _pairingRequiredRequestId);", source); + AssertInOrder( + source, + "Volatile.Write(ref _pairingRequiredRequestId, pairingDetails.RequestId);", + "Volatile.Write(ref _pairingRequiredAwaitingApproval, true);"); + Assert.Contains("Volatile.Write(ref _pairingRequiredAwaitingApproval, false);", source); + Assert.Contains("Volatile.Write(ref _pairingRequiredRequestId, null);", source); + Assert.Contains("Volatile.Write(ref _pairingRequiredAwaitingApproval, true);", source); + } + + [Fact] + public void OperatorCli_DefersIdentityDefaultsUntilAfterArgumentParsing() + { + var source = ReadSource("src", "OpenClaw.Cli", "Program.cs"); + + Assert.Contains("public string SettingsPath { get; set; } = \"\";", source); + Assert.Contains("public string IdentityDataPath { get; set; } = \"\";", source); + AssertInOrder( + source, + "options = ParseArgs(args);", + "ApplyIdentityDefaults(options, Environment.GetEnvironmentVariable);"); + AssertInOrder( + source, + "options.Identity = OpenClawAppIdentity.ResolveIdentity(envLookup, options.Identity);", + "options.IdentityDataPath = OpenClawAppIdentity.ResolveRoamingDataDirectory(envLookup, options.Identity);"); + } + + [Fact] + public void SetupKeepalive_DisposesProcessWrapperAfterWritingMarker() + { + var source = ReadSource("src", "OpenClaw.SetupEngine", "SetupSteps.cs"); + + Assert.Contains("using var proc = System.Diagnostics.Process.Start(psi);", source); + Assert.Contains("WriteKeepaliveMarker(ctx, markerPath, proc.Id);", source); + } + private static string ReadSource(params string[] relativePathParts) { var root = TestRepositoryPaths.GetRepositoryRoot(); return File.ReadAllText(Path.Combine(new[] { root }.Concat(relativePathParts).ToArray())); } + + private static void AssertInOrder(string source, params string[] snippets) + { + var previous = -1; + foreach (var snippet in snippets) + { + var current = source.IndexOf(snippet, previous + 1, StringComparison.Ordinal); + Assert.True(current > previous, $"Expected to find '{snippet}' after index {previous}."); + previous = current; + } + } } diff --git a/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs index 44ccc1883..4f3ed7fcd 100644 --- a/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs +++ b/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs @@ -3,6 +3,7 @@ using System.Runtime.Versioning; using System.Security.AccessControl; using System.Security.Principal; +using OpenClaw.Shared; using OpenClaw.WinNode.Cli; namespace OpenClaw.WinNode.Cli.Tests; @@ -198,6 +199,81 @@ public void ResolveTokenPath_falls_back_to_AppData_OpenClawTray() Assert.Equal(expected, path); } + [Fact] + public void ResolveTokenPath_uses_OPENCLAW_APP_IDENTITY_dev_when_set() + { + Func env = key => + key == OpenClawAppIdentity.IdentityEnvironmentVariable ? OpenClawAppIdentity.DevIdentity : null; + var path = CliRunner.ResolveTokenPath(env); + var expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray-Dev", + "mcp-token.txt"); + Assert.Equal(expected, path); + } + + [Fact] + public void ResolveTokenPath_explicit_identity_overrides_environment() + { + Func env = key => + key == OpenClawAppIdentity.IdentityEnvironmentVariable ? OpenClawAppIdentity.DevIdentity : null; + var path = CliRunner.ResolveTokenPath(env, OpenClawAppIdentity.ReleaseIdentity); + var expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray", + "mcp-token.txt"); + Assert.Equal(expected, path); + } + + [Fact] + public void ResolveTokenPath_data_dir_override_wins_over_identity() + { + Func env = key => key switch + { + OpenClawAppIdentity.DataDirectoryOverrideEnvironmentVariable => @"C:\sandbox", + OpenClawAppIdentity.IdentityEnvironmentVariable => OpenClawAppIdentity.DevIdentity, + _ => null + }; + + var path = CliRunner.ResolveTokenPath(env); + + Assert.Equal(Path.Combine(@"C:\sandbox", "mcp-token.txt"), path); + } + + [Fact] + public void ParseArgs_accepts_identity_dev() + { + var options = CliRunner.ParseArgs(["--list-tools", "--identity", "dev"]); + + Assert.Equal(OpenClawAppIdentity.DevIdentity, options.Identity); + } + + [Fact] + public void ParseArgs_rejects_unknown_identity() + { + var ex = Assert.Throws(() => + CliRunner.ParseArgs(["--list-tools", "--identity", "staging"])); + + Assert.Contains("release", ex.Message); + Assert.Contains("dev", ex.Message); + } + + [Fact] + public async Task OPENCLAW_APP_IDENTITY_invalid_value_returns_argument_error() + { + using var server = new FakeMcpServer(); + var (o, e) = Buffers(); + + var exit = await CliRunner.RunAsync( + ["--list-tools", "--mcp-url", server.Url], + o, + e, + key => key == OpenClawAppIdentity.IdentityEnvironmentVariable ? "staging" : null); + + Assert.Equal(2, exit); + Assert.Contains("App identity must be", e.ToString()); + } + [Theory] [InlineData("token with space")] // F-06: internal whitespace [InlineData("token\rwith-CR")] // F-06: internal CR (Trim doesn't catch)