diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e70126015..fb70f4ec8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -436,6 +436,12 @@ File-based logging with automatic rotation: - Rotation: When log exceeds 5MB, old log → `openclaw-tray.log.old` - Thread-safe: Uses lock for concurrent writes +**Easy-button setup diagnostics:** +- Human summary: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` +- Machine-readable latest trace: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl` +- Per-run traces: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\setup-*.jsonl` +- Contents are redacted and cover setup phases, WSL commands, pairing, gateway checks, repair, and remove lifecycle steps. + **Log Levels:** - `INFO` - Normal operation (connections, events) - `WARN` - Recoverable issues (reconnects, timeouts) diff --git a/README.md b/README.md index f51fae84f..bb92e8be4 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,8 @@ openclaw-windows-node/ Settings are stored in: - Settings: `%APPDATA%\OpenClawTray\settings.json` - Logs: `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` +- Easy-button setup summary: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` +- Easy-button setup JSONL: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl` Default gateway: `ws://localhost:18789` diff --git a/docs/SETUP.md b/docs/SETUP.md index 2ae877136..556b4e2d4 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -139,6 +139,7 @@ Download and install WebView2 from [Microsoft](https://developer.microsoft.com/m - Make sure the OpenClaw gateway process is running. - Check Windows Firewall — if your gateway runs on a different machine, allow inbound traffic on port 18789. - See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for connection errors. +- For easy-button setup, repair, or remove failures, start with `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt`; Copilot CLI/debugging tools can use `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl`. ### "Not yet paired" message on reconnect @@ -155,6 +156,7 @@ See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for - Make sure you paste the **entire** setup code — it's a single base64url-encoded string. - Check for accidental leading/trailing whitespace. - The code must be from a compatible gateway version. Try entering the gateway URL and token manually instead. +- If the easy-button setup flow generated the code, check `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` for the failing phase and next action. ### Connection test fails @@ -162,6 +164,7 @@ See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for - Check that your token is valid and hasn't expired. - If the gateway is on another machine, ensure Windows Firewall allows traffic on the gateway port. - See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for detailed error messages. +- Easy-button setup diagnostics keep per-run JSONL traces at `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\setup-*.jsonl` and update `easy-setup-latest.txt`/`.jsonl` after each run. ### Wizard shows "offline" diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 674f018d0..de1ad8f03 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -70,6 +70,32 @@ By removing the hardcoded `FileVersion` and `AssemblyVersion` properties, they n 2. **Let GitVersion and CI control the version** - the csproj's `` is just a fallback for local development builds 3. **Test version detection** - after building, check the EXE properties to ensure FileVersion matches expectations 4. **Use semantic versioning** - tags should follow `v{major}.{minor}.{patch}` format (e.g., `v0.4.0`) +5. **Use `OpenClaw.Shared.AppVersionInfo` for any user-visible or wire-exposed version string** - never re-roll + `typeof(...).Assembly.GetName().Version` or hardcode literals like `"v0.1.0"`. `AppVersionInfo` is the single + source of truth driven by ``, used by the About page, Update dialog, support-context dump, + `device.info` capability, MCP `serverVersion` handshake, and the update-check diagnostics. + +## Runtime Version Resolution (AppVersionInfo) + +`src/OpenClaw.Shared/AppVersionInfo.cs` exposes: + +- `AppVersionInfo.Version` → bare string, e.g. `"0.4.7"` +- `AppVersionInfo.DisplayVersion` → `"v"` prefix, e.g. `"v0.4.7"` + +It resolves the version by: + +1. Looking for the `OpenClaw.Tray.WinUI` assembly in the current `AppDomain` (so `dotnet test` and CLI siblings + still report the tray's version rather than the testhost / dotnet host). +2. Falling back to `Assembly.GetEntryAssembly()`, then to the Shared assembly. +3. Reading `AssemblyInformationalVersionAttribute` (preferred) or `AssemblyVersion`. +4. Stripping SourceLink build metadata (`+abc123`) **and** the SemVer pre-release suffix (`-beta.1`) so the + displayed value matches what Updatum compares (Updatum reads the numeric `AssemblyVersion` only). + +For tests that need a deterministic value regardless of host process, set the `internal` test hook: + +```csharp +AppVersionInfo.TestOverride = "9.9.9"; +``` ## References diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index f81baac5a..4404621f2 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -113,6 +113,19 @@ When the node connects, it advertises these capabilities: - If you see "Camera access blocked", enable camera access for desktop apps in Windows Privacy settings - Packaged MSIX builds will show the system consent prompt automatically +### MXC sandbox filesystem grants +- `system.run` with sandboxing enabled uses MXC AppContainer filesystem grants. The cwd is granted **read-only** automatically when it is not already covered by an explicit grant. If a command needs to write in its cwd, grant that folder read-write in Sandbox settings. +- As a temporary MXC compatibility workaround, OpenClaw adds read-only grants for the drive roots used by sandbox grants and shell startup. `cmd.exe` currently stats the drive root during startup; this workaround should be removed after MXC no longer requires it. +- MXC filesystem filtering requires NTFS-backed paths. ReFS volumes do not have the required filter-driver behavior, so grants on ReFS paths can fail with `Access is denied` even when the policy includes the path. +- MXC integration tests self-skip on GitHub Actions because MXC/AppContainer filesystem behavior depends on local Windows sandbox support. Run MXC tests from an NTFS-backed checkout/output folder on a local Windows machine after building the tray app so `wxc-exec.exe` has been copied into `OpenClaw.Tray.WinUI\bin\...\tools\mxc\\`: + + ```powershell + .\build.ps1 + $env:OPENCLAW_RUN_INTEGRATION='1' + $env:OPENCLAW_WXC_EXEC='D:\github\moltbot-windows-hub\src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.22621.0\win-x64\tools\mxc\x64\wxc-exec.exe' + dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --filter "FullyQualifiedName~Mxc" + ``` + ## Remaining Work (Roadmap) 1. ~~**system.run + exec approvals**~~ ✅ Implemented diff --git a/package.json b/package.json index 82b632b94..7646d733e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openclaw-windows-node-mxc", "version": "0.0.0", "private": true, - "description": "Node bridge dependencies for the OpenClaw tray's MXC sandbox integration. The C# tray spawns node.exe with scripts under tools/mxc/ that consume the @microsoft/mxc-sdk to drive wxc-exec.exe.", + "description": "MXC sandbox dependency used by the OpenClaw tray build to copy wxc-exec.exe into the app output.", "dependencies": { "@microsoft/mxc-sdk": "^0.1.8" } diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 index 2eaab1c89..fc94a6a32 100644 --- a/scripts/validate-wsl-gateway-uninstall.ps1 +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -233,6 +233,7 @@ $settingsPath = Join-Path $appData "OpenClawTray\settings.json" $logsDir = Join-Path $localAppData "OpenClawTray\Logs" $execPolicyPath = Join-Path $localAppData "OpenClawTray\exec-policy.json" $vhdDirPath = Join-Path $localAppData "OpenClawTray\wsl\$DistroName" +$wslParentDirPath = Join-Path $localAppData "OpenClawTray\wsl" $autoStartRegKey = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" $autoStartAppName = "OpenClawTray" @@ -374,6 +375,7 @@ function Get-StateSnapshot { settings_exists = (Test-Path -LiteralPath $settingsPath) exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath) vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath) + wsl_parent_dir_exists = (Test-Path -LiteralPath $wslParentDirPath) } processes_openclaw = @() } @@ -477,6 +479,7 @@ function Get-Postconditions { mcp_token_preserved = $mcpTokenPreserved keepalives_absent = $keepalivesAbsent vhd_dir_absent = (-not (Test-Path -LiteralPath $vhdDirPath)) + wsl_parent_dir_absent = (-not (Test-Path -LiteralPath $wslParentDirPath)) } } @@ -488,7 +491,8 @@ function Get-Verdict { # Required postconditions (device_key_file_preserved and mcp_token_preserved are advisory). $required = @('wsl_distro_absent', 'autostart_cleared', 'setup_state_absent', - 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent') + 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent', + 'wsl_parent_dir_absent') $failedKeys = @($required | Where-Object { $Postconditions[$_] -ne $true }) $errCount = if ($null -eq $Errors) { 0 } else { @($Errors).Count } diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs index d1d9b0b5f..1df3a1166 100644 --- a/src/OpenClaw.Chat/ChatTimelineReducer.cs +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -191,18 +191,32 @@ static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, b if (replace && reconcilePrevious && state.Entries.Count > 0) { - var lastIndex = state.Entries.Count - 1; - var last = state.Entries[lastIndex]; - if (last.Kind == ChatTimelineItemKind.Assistant) + // Scan backward for the most recent Assistant entry — not just + // the very last one. The gateway can emit a final message + // event AFTER tool entries have been appended (text → tool → + // tool output → final text), in which case the immediate last + // entry is a ToolCall. Without this scan, the final message + // would create a brand-new assistant entry that duplicates + // the streaming text the user already saw before the tool ran. + for (var li = state.Entries.Count - 1; li >= 0; li--) { - return state with + var candidate = state.Entries[li]; + if (candidate.Kind == ChatTimelineItemKind.Assistant) { - Entries = state.Entries.SetItem(lastIndex, last with + return state with { - Text = text, - IsStreaming = streaming - }) - }; + Entries = state.Entries.SetItem(li, candidate with + { + Text = text, + IsStreaming = streaming + }) + }; + } + // Stop scanning once we hit a User entry — that's a turn + // boundary, the assistant entry above it belongs to a + // previous turn and must not be reconciled into. + if (candidate.Kind == ChatTimelineItemKind.User) + break; } } diff --git a/src/OpenClaw.Shared/AppVersionInfo.cs b/src/OpenClaw.Shared/AppVersionInfo.cs new file mode 100644 index 000000000..3f292bc58 --- /dev/null +++ b/src/OpenClaw.Shared/AppVersionInfo.cs @@ -0,0 +1,101 @@ +using System; +using System.Reflection; + +namespace OpenClaw.Shared; + +/// +/// Single source of truth for the OpenClaw Companion app version that is +/// surfaced to users. Reads +/// (or falls back to AssemblyVersion) from the tray executable so every +/// UI/diagnostic/handshake site reports the same number driven by the csproj +/// <Version> property. +/// +/// +/// Under dotnet test and inside CLI siblings, +/// is the host process (testhost / dotnet), not the tray exe — so we first +/// search the current for the tray assembly by name. +/// Tests that need a deterministic value can set . +/// +public static class AppVersionInfo +{ + private const string TrayAssemblyName = "OpenClaw.Tray.WinUI"; + + private static readonly string _version = ResolveVersion(); + + /// + /// Test-only override. When non-null, returns this + /// value instead of the reflected one, giving tests a deterministic + /// version string regardless of the host process. + /// + internal static string? TestOverride { get; set; } + + /// Bare version string, e.g. "0.4.7". + public static string Version => TestOverride ?? _version; + + /// Version prefixed with v, e.g. "v0.4.7". + public static string DisplayVersion => "v" + Version; + + private static string ResolveVersion() + { + try + { + var assembly = FindTrayAssembly() + ?? Assembly.GetEntryAssembly() + ?? typeof(AppVersionInfo).Assembly; + + var informational = assembly + .GetCustomAttribute() + ?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + return NormalizeSemVer(informational); + } + + var name = assembly.GetName().Version; + if (name != null) + { + // System.Version uses -1 for unspecified components; coerce to 0. + var build = Math.Max(0, name.Build); + var revision = Math.Max(0, name.Revision); + return revision == 0 + ? $"{name.Major}.{name.Minor}.{build}" + : $"{name.Major}.{name.Minor}.{build}.{revision}"; + } + + return "0.0.0"; + } + catch + { + // Never let the type initializer fail — a TypeInitializationException + // would poison every future access from every caller. + return "0.0.0"; + } + } + + private static Assembly? FindTrayAssembly() + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (string.Equals(asm.GetName().Name, TrayAssemblyName, StringComparison.Ordinal)) + return asm; + } + return null; + } + + private static string NormalizeSemVer(string s) + { + // Strip SourceLink build metadata, e.g. "0.4.7+abc123" -> "0.4.7". + var plus = s.IndexOf('+'); + if (plus >= 0) s = s.Substring(0, plus); + + // Strip the SemVer pre-release suffix, e.g. "0.4.7-beta.1" -> "0.4.7". + // This keeps the UI string aligned with Updatum, which compares the + // numeric AssemblyVersion only. Revisit if pre-release labels should + // ever be surfaced to users. + var dash = s.IndexOf('-'); + if (dash >= 0) s = s.Substring(0, dash); + + return s; + } +} + diff --git a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs index 2c05ae9ac..01298377a 100644 --- a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Net.NetworkInformation; -using System.Reflection; using System.Runtime.InteropServices; using System.Threading.Tasks; @@ -53,10 +52,7 @@ private NodeInvokeResponse HandleInfo() { Logger.Info("device.info"); - var assembly = typeof(DeviceCapability).Assembly; - var version = assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString() - ?? "unknown"; + var version = AppVersionInfo.Version; return Success(new { @@ -65,7 +61,7 @@ private NodeInvokeResponse HandleInfo() systemName = OperatingSystem.IsWindows() ? "Windows" : RuntimeInformation.OSDescription, systemVersion = RuntimeInformation.OSDescription, appVersion = version, - appBuild = assembly.GetName().Version?.ToString() ?? version, + appBuild = version, locale = CultureInfo.CurrentCulture.Name }); } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs index 9e74a32f2..3c1658fbb 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs @@ -11,7 +11,9 @@ public enum ExecApprovalV2Code AllowlistMiss, UserDenied, ValidationFailed, - ResolutionFailed + ResolutionFailed, + InternalError, // invariant violations and unexpected internal bugs detected at runtime + Allow, // coordinator approved; caller may execute the command } /// @@ -50,5 +52,13 @@ public static ExecApprovalV2Result ValidationFailed(string reason) public static ExecApprovalV2Result ResolutionFailed(string reason) => new(ExecApprovalV2Code.ResolutionFailed, reason); + public static ExecApprovalV2Result InternalError(string reason) + => new(ExecApprovalV2Code.InternalError, reason); + + public static ExecApprovalV2Result Allow() + => new(ExecApprovalV2Code.Allow, "approved"); + + public bool IsAllow => Code == ExecApprovalV2Code.Allow; + public override string ToString() => $"{Code}: {Reason}"; } diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs new file mode 100644 index 000000000..28df41b55 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; + +namespace OpenClaw.Shared.ExecApprovals; + +// Full coordinator pipeline: validate → normalize → buildContext → evaluate(pass1) → +// prompt/fallback → [persistAllowlistEntry stub] → evaluate(pass2) → final decision. +// Rail 10: no WinUI types. Rail 17: SemaphoreSlim serializes the prompt+pass2 block. +// Rail 19: not wired in production src in PR7 — verified by test 15. +// Must be registered as singleton when wired (PR8+): the SemaphoreSlim is per-instance. +public sealed class ExecApprovalsCoordinator : IExecApprovalV2Handler +{ + private readonly ExecApprovalsStore _store; + private readonly ICanPresentEvaluator _canPresent; + private readonly IExecApprovalV2PromptHandler _prompt; + private readonly IOpenClawLogger _logger; + + // Serializes the prompt call + second-pass block (rail 17). + // Does NOT protect validate/normalize/buildContext — those are stateless. + private readonly SemaphoreSlim _promptLock = new(1, 1); + + public ExecApprovalsCoordinator( + ExecApprovalsStore store, + ICanPresentEvaluator canPresentEvaluator, + IExecApprovalV2PromptHandler promptHandler, + IOpenClawLogger logger) + { + _store = store; + _canPresent = canPresentEvaluator; + _prompt = promptHandler; + _logger = logger; + } + + public async Task HandleAsync(NodeInvokeRequest request, string correlationId) + { + if (string.IsNullOrEmpty(correlationId)) + correlationId = Guid.NewGuid().ToString("N"); + + try + { + // Step 1: validate + var validation = ExecApprovalV2InputValidator.Validate(request); + if (!validation.IsValid) + return LogAndReturn(validation.Error!, correlationId, + promptAttempted: false, fallbackUsed: false); + + // Step 2: normalize (unwrap shell wrappers, resolve executables, build canonical identity) + var norm = ExecApprovalV2Normalizer.Normalize(validation.Request!); + if (!norm.IsResolved) + return LogAndReturn(norm.Error!, correlationId, + promptAttempted: false, fallbackUsed: false); + var identity = norm.Identity!; + + // Step 3: buildContext + var resolved = _store.ResolveReadOnly(identity.AgentId); + + // Env injection guard — preserves SystemCapability.HandleRunAsync:343-351 behavior. + // identity.Env is IReadOnlyDictionary; copy to Dictionary for Sanitize. + var envInput = identity.Env is null + ? null + : new Dictionary(identity.Env, StringComparer.OrdinalIgnoreCase); + var envResult = ExecEnvSanitizer.Sanitize(envInput); + + if (envResult.Blocked.Length > 0) + { + var blockedNames = (string[])envResult.Blocked.Clone(); + Array.Sort(blockedNames, StringComparer.OrdinalIgnoreCase); + _logger.Warn($"[EXEC-APPROVALS] [{correlationId}] env-blocked: [{string.Join(", ", blockedNames)}]"); + return LogAndReturn(ExecApprovalV2Result.ValidationFailed("env-blocked"), + correlationId, promptAttempted: false, fallbackUsed: false); + } + + var sanitizedEnv = envResult.Allowed as IReadOnlyDictionary; + IReadOnlyList matches = resolved.Defaults.Security == ExecSecurity.Allowlist + ? ExecAllowlistMatcher.MatchAll(resolved.Allowlist, identity.AllowlistResolutions) + : []; + + var context = new ExecApprovalEvaluation( + identity.Command, + identity.DisplayCommand, + identity.AgentId, + resolved.Defaults.Security, + resolved.Defaults.Ask, + sanitizedEnv, + identity.AllowlistResolutions, + identity.AllowAlwaysPatterns, + matches); + + // Step 4: first pass (approvalDecision always null in PR7 — CVE #8682, ADR-0002 Phase 2) + var pass1 = ExecApprovalEvaluator.Evaluate(context, null); + if (pass1 is ExecHostPolicyDecision.DenyOutcome denyPass1) + return LogAndReturn(denyPass1.Error, correlationId, + promptAttempted: false, fallbackUsed: false, canonical: context.DisplayCommand); + if (pass1 is ExecHostPolicyDecision.AllowOutcome) + { + // Pre-approved path (security=Full, ask=Off or allowlist satisfied): skip prompt + _logger.Info($"[EXEC-APPROVALS] [{correlationId}] path=new " + + $"canonical=\"{SanitizeForLog(context.DisplayCommand)}\" decision=allow " + + $"reason=approved fallbackUsed=false promptAttempted=false"); + return ExecApprovalV2Result.Allow(); + } + // RequiresPromptOutcome → continue to prompt/fallback block + + // Steps 5-7: prompt/fallback + second pass (critical section) + bool promptAttempted = false; + bool fallbackUsed = false; + + await _promptLock.WaitAsync().ConfigureAwait(false); + try + { + ExecApprovalDecision followupDecision; + + if (_canPresent.CanPresent(identity.SessionKey)) + { + promptAttempted = true; + ExecApprovalPromptOutcome promptResult; + try + { + promptResult = await _prompt.PromptAsync( + BuildPromptRequest(context, identity, correlationId), + cancellationToken: default).ConfigureAwait(false); + } + catch + { + // Presenter failure → fail-closed, no fallback delegation + return LogAndReturn(ExecApprovalV2Result.UserDenied("prompt-failed"), + correlationId, promptAttempted: true, fallbackUsed: false, + canonical: context.DisplayCommand); + } + + // Allow (plain) from a prompt handler is an invariant violation — + // only AllowOnce and AllowAlways are semantically valid from UI. + if (promptResult == ExecApprovalPromptOutcome.Allow) + { + _logger.Error($"[EXEC-APPROVALS] [{correlationId}] invariant: " + + "prompt returned Allow — treating as invariant violation deny"); + return LogAndReturn(ExecApprovalV2Result.InternalError("prompt-returned-allow"), + correlationId, promptAttempted: true, fallbackUsed: false, + canonical: context.DisplayCommand); + } + + // Exhaustive mapping without _ so the compiler warns if ExecApprovalPromptOutcome + // gains a new value. Allow is unreachable here — handled by the check above. + followupDecision = promptResult switch + { + ExecApprovalPromptOutcome.Deny => ExecApprovalDecision.Deny, + ExecApprovalPromptOutcome.AllowOnce => ExecApprovalDecision.AllowOnce, + ExecApprovalPromptOutcome.AllowAlways => ExecApprovalDecision.AllowAlways, + ExecApprovalPromptOutcome.Allow => throw new UnreachableException("prompt-returned-allow handled above"), + }; + } + else + { + fallbackUsed = true; + followupDecision = FallbackDecision(context, resolved.Defaults.AskFallback); + } + + // Step 6: AddAllowlistEntry stub (PR9 implements for AllowAlways + security==Allowlist) + + // Step 7: second pass — must never return RequiresPrompt + var pass2 = ExecApprovalEvaluator.Evaluate(context, followupDecision); + if (pass2 is ExecHostPolicyDecision.DenyOutcome denyPass2) + return LogAndReturn(denyPass2.Error, correlationId, promptAttempted, fallbackUsed, + canonical: context.DisplayCommand); + if (pass2 is ExecHostPolicyDecision.RequiresPromptOutcome) + { + _logger.Error($"[EXEC-APPROVALS] [{correlationId}] invariant: " + + "second pass returned RequiresPrompt"); + return LogAndReturn(ExecApprovalV2Result.InternalError("second-pass-requires-prompt"), + correlationId, promptAttempted, fallbackUsed, canonical: context.DisplayCommand); + } + // AllowOutcome → fall through to steps 8-10 + } + finally + { + _promptLock.Release(); + } + + // Step 8: RecordAllowlistUse stub (PR9) + + // Step 9: final allow log + _logger.Info($"[EXEC-APPROVALS] [{correlationId}] path=new " + + $"canonical=\"{SanitizeForLog(context.DisplayCommand)}\" decision=allow " + + $"reason=approved fallbackUsed={fallbackUsed} promptAttempted={promptAttempted}"); + + // Step 10: return Allow + return ExecApprovalV2Result.Allow(); + } + catch (Exception ex) + { + // Outer safety net: any unhandled exception in buildContext, CanPresent, FallbackDecision, + // or an out-of-range prompt outcome produces a typed deny instead of escaping HandleAsync. + // Rail 1: failures in the new path must never be silent or untyped. + var msg = $"[EXEC-APPROVALS] [{correlationId}] path=new " + + $"canonical=\"\" decision=deny reason=unexpected-exception " + + $"fallbackUsed=false promptAttempted=false"; + _logger.Error(msg, ex); + return ExecApprovalV2Result.InternalError("unexpected-exception"); + } + } + + // Fail-safe defaults when no UI is available (Saltzer/Schroeder fail-safe defaults, OWASP ASVS 4.1.4). + // ask=Always → Deny: human approval is a precondition; without UI the only safe outcome is deny. + private static ExecApprovalDecision FallbackDecision( + ExecApprovalEvaluation context, + ExecAsk askFallback) + { + return askFallback switch + { + ExecAsk.Off => ExecApprovalDecision.AllowOnce, + ExecAsk.OnMiss => context.AllowlistSatisfied + ? ExecApprovalDecision.AllowOnce + : ExecApprovalDecision.Deny, + ExecAsk.Always => ExecApprovalDecision.Deny, + ExecAsk.Deny => ExecApprovalDecision.Deny, + _ => ExecApprovalDecision.Deny, // defensive + }; + } + + private static ExecApprovalV2PromptRequest BuildPromptRequest( + ExecApprovalEvaluation context, + CanonicalCommandIdentity identity, + string correlationId) + => new() + { + DisplayCommand = context.DisplayCommand, // NOT sanitized — presenter's responsibility (rail 11) + Cwd = identity.Cwd, + Security = context.Security, + Ask = context.Ask, + AgentId = context.AgentId ?? "main", + ResolvedPath = context.Resolution?.ResolvedPath, + SessionKey = identity.SessionKey, + CorrelationId = correlationId, + // Host omitted in PR7 (no gateway wiring yet) + }; + + // Anti log-injection: replaces control characters in DisplayCommand before writing to logs. + // Truncates to 200 chars — sufficient for triage, bounded for disk-bound logs. + private static string SanitizeForLog(string? value) + { + if (string.IsNullOrEmpty(value)) return ""; + Span buffer = stackalloc char[Math.Min(value.Length, 200)]; + var count = 0; + foreach (var ch in value) + { + if (count == buffer.Length) break; + buffer[count++] = char.IsControl(ch) ? ' ' : ch; + } + var sanitized = new string(buffer[..count]); + return value.Length > count ? sanitized + "..." : sanitized; + } + + private ExecApprovalV2Result LogAndReturn( + ExecApprovalV2Result result, + string correlationId, + bool promptAttempted, + bool fallbackUsed, + string? canonical = null) + { + var safeCanonical = SanitizeForLog(canonical); + var msg = $"[EXEC-APPROVALS] [{correlationId}] path=new " + + $"canonical=\"{safeCanonical}\" decision=deny reason={result.Reason} " + + $"fallbackUsed={fallbackUsed} promptAttempted={promptAttempted}"; + if (result.Code == ExecApprovalV2Code.InternalError) + _logger.Error(msg); + else + _logger.Warn(msg); + return result; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs b/src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs new file mode 100644 index 000000000..7e53c244e --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs @@ -0,0 +1,30 @@ +namespace OpenClaw.Shared.ExecApprovals; + +// Determines whether the coordinator can present a UI prompt for this request. +// Doc 08 F1 lists four inputs to canPresent: requestSessionKey, activeSessionKey, +// lastInputSeconds, desktopInteractive. Only requestSessionKey is passed by the +// coordinator — the other three are encapsulated inside the implementation: +// activeSessionKey: provided by whatever tracks the active tray session. +// lastInputSeconds: read via Win32 GetLastInputInfo (OQ-F1). +// desktopInteractive: read via OpenInputDesktop / WTSQuerySessionInformation (OQ-F1). +// Keeping these out of the interface keeps the coordinator UI-free (rail 10) and +// testable without Win32. Must never throw — fail to false (no UI available). +public interface ICanPresentEvaluator +{ + bool CanPresent(string? requestSessionKey); +} + +// Default for PR7: UI not wired yet. Everything routes to FallbackDecision. +public sealed class AlwaysCannotPresentEvaluator : ICanPresentEvaluator +{ + public static readonly AlwaysCannotPresentEvaluator Instance = new(); + public bool CanPresent(string? requestSessionKey) => false; +} + +// Test double: always reports UI available. Used in coordinator tests to +// exercise the prompt path with the null prompt handler. +public sealed class AlwaysCanPresentEvaluator : ICanPresentEvaluator +{ + public static readonly AlwaysCanPresentEvaluator Instance = new(); + public bool CanPresent(string? requestSessionKey) => true; +} diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index 2bae20005..91379e095 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -270,6 +270,20 @@ private object HandleToolsList() "Get tray menu state (status, session count, node count). Returns array of menu items.", ["app.search"] = "Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.", + + // location.* + ["location.get"] = + "Get the current device location via Windows.Devices.Geolocation. Args: accuracy ('default'|'high', optional, default 'default'), maxAge (int ms, optional, default 30000 — return a cached fix if it is younger than this), locationTimeout (int ms, optional, default 10000). Returns { latitude, longitude, accuracy (meters), timestamp (ms since epoch) }. Requires Location capability to be enabled and the user to have granted location permission to the app.", + + // device.* + ["device.info"] = + "Get static device metadata. No args. Returns { deviceName, modelIdentifier, systemName, systemVersion, appVersion, appBuild, locale }.", + ["device.status"] = + "Get live system health data. Args: sections (string[], optional — subset of ['os','cpu','memory','disk','battery']; omit for all). Returns a map with a 'collectedAt' timestamp and one key per requested section. Each section may contain an 'error' field if collection failed. Also includes legacy fields: thermal, storage, network, uptimeSeconds.", + + // browser.* + ["browser.proxy"] = + "Proxy an HTTP request to the local OpenClaw browser control host (CDP server) running on gateway port + 2. Args: path (string, required — a local control path like '/json/list' or '/json/activate/'), method ('GET'|'POST'|'DELETE', default 'GET'), body (JSON object, POST/DELETE only), query (object, appended as query params), profile (string, optional browser profile), timeoutMs (int, default 20000, max 120000). Returns { result, files? } where files is present if the response included local file paths. Requires the gateway URL to have an explicit port and the browser control host to be running.", }; private async Task HandleToolsCallAsync(JsonElement parameters, CancellationToken cancellationToken) diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs new file mode 100644 index 000000000..43a175023 --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Implements by spawning wxc-exec.exe +/// directly via . No Node.js runtime required. +/// +/// +/// Responsibilities: +/// +/// Per-invocation scratch dir lifecycle. +/// Logging the final before sending — structured +/// summary by default; full JSON when is set. +/// Host-side timeout + cancel + process-tree kill via +/// 's CancellationToken plumbing. +/// Cmd-line overflow handling — falls back to --config <file> +/// when the base64'd config exceeds the Windows command-line limit. +/// +/// +public sealed class DirectAppContainerExecutor : ISandboxExecutor +{ + public string Name => "mxc-direct-appc"; + public bool IsContained => true; + + /// Default cap on stdout/stderr returned to the host (4 MiB). + public const long DefaultMaxOutputBytes = 4 * 1024 * 1024; + + /// + /// When set to "1", the executor logs the FULL config JSON (paths, command + /// line) instead of the redacted summary. Env values are still keys-only. + /// Use for sandbox-debugging only; the redacted summary is the default. + /// + public const string LogFullConfigEnvVar = "OPENCLAW_MXC_LOG_FULL_CONFIG"; + + /// + /// Threshold for base64'd config length above which we switch to + /// --config <file>. Windows cmdline is capped near 32k chars; + /// 25k leaves headroom for the executable path and other args. + /// + private const int Base64ConfigCharLimit = 25_000; + + private readonly MxcAvailability _availability; + private readonly IOpenClawLogger _logger; + + public DirectAppContainerExecutor(MxcAvailability availability, IOpenClawLogger? logger = null) + { + _availability = availability; + _logger = logger ?? NullLogger.Instance; + } + + public async Task ExecuteAsync( + SandboxExecutionRequest request, + CancellationToken ct = default) + { + if (!_availability.IsAppContainerAvailable) + throw new SandboxUnavailableException( + _availability.UnsupportedReasons.FirstOrDefault() ?? "AppContainer unavailable"); + + if (!_availability.IsWxcExecResolvable || string.IsNullOrEmpty(_availability.WxcExecPath)) + throw new SandboxUnavailableException("wxc-exec.exe not found"); + + var capBytes = request.MaxOutputBytes is > 0 ? request.MaxOutputBytes.Value : DefaultMaxOutputBytes; + var capInt = capBytes > int.MaxValue ? int.MaxValue : (int)capBytes; + + var scratchDir = CreateScratchDir(); + string? tempConfigFile = null; + var sw = Stopwatch.StartNew(); + try + { + var config = MxcConfigBuilder.Build(request, scratchDir); + var configJson = JsonSerializer.Serialize(config, ConfigJson); + var launchWorkingDirectory = string.IsNullOrWhiteSpace(config.Process.Cwd) + ? null + : config.Process.Cwd; + + WarnIfUnsupportedVolume(config); + LogConfig(config, configJson, request); + + MxcExecutor executor; + try + { + executor = new MxcExecutor(_availability.WxcExecPath, stdoutCapBytes: capInt, stderrCapBytes: capInt); + } + catch (FileNotFoundException ex) + { + throw new SandboxUnavailableException($"wxc-exec.exe not found at {_availability.WxcExecPath}", ex); + } + + // Local timeout + caller cancellation. Mirror the builder's + // effective timeout (request.TimeoutMs > 0 ? request.TimeoutMs : + // DefaultProcessTimeoutMs) so a request with TimeoutMs=0 doesn't + // skip the host-side bound entirely. Add a grace window above + // the per-process timeout so wxc-exec has a chance to clean up + // before we kill its process tree. + var effectiveTimeoutMs = request.TimeoutMs > 0 + ? request.TimeoutMs + : MxcConfigBuilder.DefaultProcessTimeoutMs; + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + linked.CancelAfter(effectiveTimeoutMs + 5_000); + + MxcResult result; + // Base64 cmdline grows ~4/3 vs the underlying JSON bytes. Count + // UTF-8 bytes (not chars) because non-ASCII text would otherwise + // under-estimate the encoded size and overflow the cmdline limit. + var configByteCount = Encoding.UTF8.GetByteCount(configJson); + var base64Len = ((configByteCount + 2) / 3) * 4; + if (base64Len <= Base64ConfigCharLimit) + { + result = await executor.RunAsync(config, linked.Token, workingDirectory: launchWorkingDirectory); + } + else + { + tempConfigFile = Path.Combine(scratchDir, "wxc-config.json"); + await File.WriteAllTextAsync(tempConfigFile, configJson, Encoding.UTF8, linked.Token); + result = await executor.RunWithConfigFileAsync(tempConfigFile, linked.Token, workingDirectory: launchWorkingDirectory); + } + + sw.Stop(); + + // Caller-cancellation precedence: if the caller's token tripped, + // surface as OperationCanceledException instead of falsely + // reporting TimedOut. Check this BEFORE the timeout branch so a + // race between caller-cancel and linked-cancel resolves correctly. + if (ct.IsCancellationRequested) + ct.ThrowIfCancellationRequested(); + + // If our linked token tripped (timeout) and the caller's didn't, surface + // as a TimedOut result rather than throwing. + var timedOut = result.TimedOut || linked.IsCancellationRequested; + if (timedOut) + { + return new SandboxExecutionResult( + ExitCode: -1, + Stdout: result.Output ?? string.Empty, + Stderr: result.Error ?? "Sandboxed invocation timed out.", + TimedOut: true, + DurationMs: result.DurationMs == 0 ? sw.ElapsedMilliseconds : result.DurationMs, + ContainmentTag: "mxc", + StructuredResult: null); + } + + return new SandboxExecutionResult( + ExitCode: result.ExitCode, + Stdout: result.Output ?? string.Empty, + Stderr: result.Error ?? string.Empty, + TimedOut: false, + DurationMs: result.DurationMs == 0 ? sw.ElapsedMilliseconds : result.DurationMs, + ContainmentTag: "mxc", + StructuredResult: null); + } + finally + { + TryDelete(tempConfigFile); + TryDeleteDir(scratchDir); + } + } + + private static string CreateScratchDir() + { + var dir = Path.Combine(Path.GetTempPath(), "openclaw-mxc-" + Guid.NewGuid().ToString("N").Substring(0, 12)); + Directory.CreateDirectory(dir); + return dir; + } + + private static void TryDelete(string? path) + { + if (string.IsNullOrEmpty(path)) return; + try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ } + } + + private static void TryDeleteDir(string? path) + { + if (string.IsNullOrEmpty(path)) return; + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* best-effort */ } + } + + private void LogConfig(MxcConfig config, string configJson, SandboxExecutionRequest request) + { + // Default: redacted summary. Field counts only; no paths, no command line, + // no env values. Useful for verifying Sandbox UI settings round-tripped + // into wxc-exec without leaking the user's filesystem layout. + var envKeys = config.Process.Env? + .Select(kv => kv.Split('=', 2)[0]) + .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); + + var summary = + "[mxc] wxc-exec config (redacted) " + + $"wxcExec={_availability.WxcExecPath}; configBytes={Encoding.UTF8.GetByteCount(configJson)}; " + + $"containerId={config.ContainerId}; version={config.Version}; " + + $"commandLineLength={config.Process.CommandLine?.Length ?? 0}; " + + $"cwd={(string.IsNullOrEmpty(config.Process.Cwd) ? "" : "")}; " + + $"envKeys=[{string.Join(",", envKeys)}]; " + + $"timeoutMs={config.Process.TimeoutMs?.ToString() ?? ""}; " + + $"capabilities=[{string.Join(",", config.AppContainer?.Capabilities ?? Array.Empty())}]; " + + $"readonlyCount={config.Filesystem?.ReadonlyPaths?.Length ?? 0}; " + + $"readwriteCount={config.Filesystem?.ReadwritePaths?.Length ?? 0}; " + + $"deniedCount={config.Filesystem?.DeniedPaths?.Length ?? 0}; " + + $"network={{defaultPolicy={config.Network?.DefaultPolicy ?? ""},enforcementMode={config.Network?.EnforcementMode ?? ""}}}; " + + $"ui={{disable={config.Ui?.Disable},clipboard={config.Ui?.Clipboard ?? ""},injection={config.Ui?.Injection}}}; " + + $"maxOutputBytes={request.MaxOutputBytes?.ToString() ?? ""}"; + _logger.Debug(summary); + Trace.WriteLine(summary); + + // Full repro: gated behind env var. Paths and command line included; + // env values still redacted (keys only) to avoid leaking caller tokens. + if (string.Equals(Environment.GetEnvironmentVariable(LogFullConfigEnvVar), "1", StringComparison.Ordinal)) + { + var redactedConfig = config with + { + Process = config.Process with + { + Env = envKeys.Select(k => k + "=").ToArray(), + } + }; + var fullJson = JsonSerializer.Serialize(redactedConfig, ConfigJson); + var fullMsg = $"[mxc] wxc-exec config (full, env-values redacted) configJson={fullJson}"; + _logger.Debug(fullMsg); + Trace.WriteLine(fullMsg); + } + } + + private void WarnIfUnsupportedVolume(MxcConfig config) + { + var paths = (config.Filesystem?.ReadonlyPaths ?? Array.Empty()) + .Concat(config.Filesystem?.ReadwritePaths ?? Array.Empty()) + .Distinct(StringComparer.OrdinalIgnoreCase); + + var warnedRoots = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in paths) + { + string? root; + try { root = Path.GetPathRoot(Path.GetFullPath(path)); } + catch { continue; } + + if (string.IsNullOrWhiteSpace(root) || !warnedRoots.Add(root)) + continue; + + try + { + var drive = new DriveInfo(root); + if (!drive.IsReady) + continue; + + if (!string.Equals(drive.DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase)) + { + _logger.Warn( + $"[mxc] filesystem grants on {drive.DriveFormat} volume {root} may fail: " + + "MXC AppContainer filesystem filtering requires NTFS-backed paths."); + } + } + catch + { + // Best-effort diagnostic only. The command result should reflect + // the real MXC failure if the volume cannot be queried. + } + } + } + + private static readonly JsonSerializerOptions ConfigJson = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} diff --git a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs index 21abae99e..69a59f53b 100644 --- a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs +++ b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs @@ -10,7 +10,7 @@ namespace OpenClaw.Shared.Mxc; /// /// Implementations: /// -/// — per-call AppContainer via Node + mxc-sdk. +/// — per-call AppContainer via direct wxc-exec.exe spawn. /// HostFallbackExecutor — when containment unavailable in BestEffort mode. /// /// All implementations are expected to throw @@ -45,9 +45,8 @@ Task ExecuteAsync( /// Pass <= 0 to let the executor use its default. /// /// -/// Maximum stdout/stderr the executor will return. Pass null to use the -/// executor's default (typically 4 MiB). The host capture cap and the bridge -/// cap (run-command.cjs) honor this value. +/// Maximum stdout/stderr the executor will return. Pass null to use +/// the executor's default (typically 4 MiB). /// public sealed record SandboxExecutionRequest( string CapabilityCommand, diff --git a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs index 9794e1dcf..a9e8cbdb3 100644 --- a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs +++ b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs @@ -9,16 +9,16 @@ namespace OpenClaw.Shared.Mxc; /// Backends checked: /// /// — Windows 11 build >= 26100, UBR >= 7965 (per @microsoft/mxc-sdk README), x64 / arm64. -/// — wxc-exec.exe found at the expected node_modules path or via override. +/// — wxc-exec.exe found in the shipped tray output layout or via override. /// — requires AppContainer plus IsolationProxy.exe in System32. /// /// public sealed class MxcAvailability { /// - /// Optional override path for wxc-exec.exe. When set, used instead of the - /// default node_modules/@microsoft/mxc-sdk/bin/<arch>/wxc-exec.exe probe. - /// Wired through environment variable OPENCLAW_WXC_EXEC. + /// Optional override path for wxc-exec.exe. When set, used instead of + /// probing the shipped tools\mxc\<arch>\wxc-exec.exe layout. Wired + /// through environment variable OPENCLAW_WXC_EXEC. /// public const string WxcExecOverrideEnvVar = "OPENCLAW_WXC_EXEC"; @@ -38,26 +38,17 @@ public sealed class MxcAvailability public bool IsWxcExecResolvable { get; } public string? WxcExecPath { get; } - /// - /// Resolved path to tools/mxc/run-command.cjs (the productized Node bridge - /// for MxcCommandRunner). The tray build copies this under the app base - /// directory; probing intentionally does not walk parent directories so a - /// user-writable parent cannot inject a replacement bridge. - /// - public string? RunCommandScriptPath { get; } - /// /// Human-readable list of reasons MXC may not be available. Empty when fully supported. /// Surface to UX so users know why the sandbox toggle is disabled. /// public IReadOnlyList UnsupportedReasons { get; } - /// True iff at least one MXC backend is supported, the bridge script is found, - /// AND wxc-exec.exe is resolvable. (Without wxc-exec the executor will refuse + /// True iff at least one MXC backend is supported AND + /// wxc-exec.exe is resolvable. (Without wxc-exec the executor will refuse /// to run, so reporting "available" would lie to the UI.) public bool HasAnyBackend => (IsAppContainerAvailable || IsIsolationSessionAvailable) - && RunCommandScriptPath is not null && IsWxcExecResolvable; public MxcAvailability( @@ -65,14 +56,12 @@ public MxcAvailability( bool isIsolationSessionAvailable, bool isWxcExecResolvable, string? wxcExecPath, - string? runCommandScriptPath, IReadOnlyList unsupportedReasons) { IsAppContainerAvailable = isAppContainerAvailable; IsIsolationSessionAvailable = isIsolationSessionAvailable; IsWxcExecResolvable = isWxcExecResolvable; WxcExecPath = wxcExecPath; - RunCommandScriptPath = runCommandScriptPath; UnsupportedReasons = unsupportedReasons; } @@ -88,7 +77,7 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null) if (!OperatingSystem.IsWindows()) { reasons.Add("MXC requires Windows."); - return new MxcAvailability(false, false, false, null, null, reasons); + return new MxcAvailability(false, false, false, null, reasons); } var (build, ubr) = ReadWindowsBuildAndUbr(); @@ -109,11 +98,7 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null) var (wxcResolvable, wxcPath) = ResolveWxcExec(); if (!wxcResolvable) - reasons.Add($"wxc-exec.exe not found. Set {WxcExecOverrideEnvVar} or run `npm ci` at the repository root."); - - var runCommandScriptPath = ResolveRunCommandScript(); - if (runCommandScriptPath is null) - reasons.Add("tools/mxc/run-command.cjs not found in any expected location."); + reasons.Add($"wxc-exec.exe not found. Set {WxcExecOverrideEnvVar} or build the tray app to copy it into the output folder."); // isolation_session additionally requires Feature_IsoBrokerSessionApis on the OS // and IsolationProxy.exe in System32. We currently only check file presence. @@ -128,7 +113,6 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null) $"[mxc] availability: appcontainer={isAppContainerSupported} " + $"isolation_session={isIsolationSessionSupported} " + $"wxc-exec={(wxcResolvable ? wxcPath : "")} " + - $"run-command.cjs={(runCommandScriptPath ?? "")} " + $"reasons=[{string.Join(", ", reasons)}]"); return new MxcAvailability( @@ -136,7 +120,6 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null) isIsolationSessionSupported, wxcResolvable, wxcPath, - runCommandScriptPath, reasons); } @@ -170,7 +153,7 @@ private static (bool resolvable, string? path) ResolveWxcExec() if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath)) return (true, overridePath); - var arch = MxcArchHelper.GetSdkArchString(); + var arch = GetSdkArchString(); var probeRoots = new[] { AppContext.BaseDirectory, @@ -182,42 +165,25 @@ private static (bool resolvable, string? path) ResolveWxcExec() if (string.IsNullOrWhiteSpace(root)) continue; - var candidate = Path.Combine( + // Preferred: tools/mxc//wxc-exec.exe — the layout the build + // target extracts to so we don't ship a node_modules/ tree. + var shipped = Path.Combine(root, "tools", "mxc", arch, "wxc-exec.exe"); + if (File.Exists(shipped)) + return (true, shipped); + + // Legacy fallback: developer builds with node_modules/ still around. + var legacy = Path.Combine( root, "node_modules", "@microsoft", "mxc-sdk", "bin", arch, "wxc-exec.exe"); - if (File.Exists(candidate)) - return (true, candidate); + if (File.Exists(legacy)) + return (true, legacy); } return (false, null); } - private static string? ResolveRunCommandScript() - { - var probeRoots = new[] - { - AppContext.BaseDirectory, - Path.GetDirectoryName(typeof(MxcAvailability).Assembly.Location) ?? string.Empty, - }; - - foreach (var root in probeRoots) - { - if (string.IsNullOrWhiteSpace(root)) - continue; - - var candidate = Path.Combine(root, "tools", "mxc", "run-command.cjs"); - if (File.Exists(candidate)) - return candidate; - } - - return null; - } -} - -internal static class MxcArchHelper -{ - /// Returns "arm64" or "x64" matching the @microsoft/mxc-sdk bin/<arch>/ layout. - public static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + /// Returns "arm64" or "x64" matching the @microsoft/mxc-sdk bin/<arch>/ layout. + private static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch { System.Runtime.InteropServices.Architecture.Arm64 => "arm64", System.Runtime.InteropServices.Architecture.X64 => "x64", diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs index 18b34a518..b14e85beb 100644 --- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -13,12 +13,9 @@ namespace OpenClaw.Shared.Mxc; /// /// Honors : /// -/// true (default) — sandbox via MXC; deny invocation if MXC unavailable. +/// true (default) — sandbox via MXC when available; fall back uncontained when MXC is unavailable. /// false — bypass MXC; route through the host runner. /// -/// There is no host-fallback path when sandbox is enabled and MXC is missing — -/// the call is denied with an explanatory error. Per user directive: "if sandbox -/// enabled, only run on sandbox." /// public sealed class MxcCommandRunner : ICommandRunner { @@ -54,26 +51,18 @@ public async Task RunAsync(CommandRequest request, CancellationTo { var settings = _settingsProvider(); - // Fail-closed when MXC is unavailable. We do NOT route to host even if the - // persisted toggle is OFF — the UI hides the toggle in that state so any - // OFF value is stale (e.g., flipped on a previous run / different machine). - // The UI's "Sandbox unavailable — commands blocked" claim must match - // actual behavior or it's a lie. + // When MXC sandboxing isn't available on this host (e.g. Windows 10, + // build < 26100, or missing wxc-exec.exe), fall back to the host runner + // so the agent can still execute commands instead of being completely + // blocked. The Sandbox page is read-only in that state and tells the + // user their commands are running uncontained. if (!_isSandboxAvailable()) { _logger.Warn( - "[mxc] system.run DENIED: sandbox unavailable. " + - "Update Windows or install missing components to enable."); - return new CommandResult - { - Stdout = string.Empty, - Stderr = - "Sandboxing is unavailable on this machine, so agent-started Windows " + - "commands are blocked. Open the Sandbox page for fix instructions.", - ExitCode = -1, - TimedOut = false, - DurationMs = 0, - }; + "[mxc] system.run UNCONTAINED: sandbox unavailable on this host. " + + "Commands will run on the host without containment. " + + "Update Windows to enable sandboxing."); + return await _hostFallback.RunAsync(request, ct); } if (!settings.SystemRunSandboxEnabled) @@ -119,25 +108,16 @@ public async Task RunAsync(CommandRequest request, CancellationTo catch (SandboxUnavailableException ex) { // Invalidate any cached availability — what we thought was available - // turned out not to be. Next command re-probes. This handles the - // case where MXC components were uninstalled (or wxc-exec moved) - // between this NodeService starting and now. + // turned out not to be at runtime. Next command re-probes and the + // top-level !_isSandboxAvailable() branch will handle the fallback. + // We also fall back to host execution for THIS call instead of + // denying it, matching the policy from issue #494. _invalidateAvailability?.Invoke(); _logger.Warn( - $"[mxc] system.run DENIED (sandbox enabled but unavailable: {ex.Message}). " + - "Disable the sandbox toggle in Debug to fall back to host execution."); - return new CommandResult - { - Stdout = string.Empty, - Stderr = - "Sandboxing is enabled for system.run on this machine, but MXC is unavailable. " + - $"Reason: {ex.Message}. " + - "Update Windows or disable the system.run sandbox in the Debug page to run on host.", - ExitCode = -1, - TimedOut = false, - DurationMs = 0, - }; + $"[mxc] system.run UNCONTAINED (sandbox enabled but unavailable at runtime: {ex.Message}). " + + "Falling back to host execution. Update Windows to enable sandboxing."); + return await _hostFallback.RunAsync(request, ct); } catch (OperationCanceledException) { @@ -196,7 +176,7 @@ private void LogSandboxRequest( $"sandboxSettingsJson={settingsJson}; " + $"shell={commandRequest.Shell ?? "powershell"}; " + $"commandLength={commandRequest.Command?.Length ?? 0}; " + - $"cwd={commandRequest.Cwd ?? ""}; " + + $"cwd={(string.IsNullOrEmpty(commandRequest.Cwd) ? "" : "")}; " + $"envKeys=[{string.Join(",", commandRequest.Env?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty())}]; " + $"timeoutMs={sandboxRequest.TimeoutMs}; maxOutputBytes={sandboxRequest.MaxOutputBytes?.ToString() ?? ""}; " + $"policyJson={policyJson}"; @@ -205,11 +185,9 @@ private void LogSandboxRequest( private static object ToSandboxSettingsDiagnostic(SettingsData settings, string settingsDirectoryPath) { - var preset = DetectPreset(settings); return new { systemRunSandboxEnabled = settings.SystemRunSandboxEnabled, - securityLevel = preset, systemRunAllowOutbound = settings.SystemRunAllowOutbound, sandboxClipboard = settings.SandboxClipboard, sandboxDocumentsAccess = settings.SandboxDocumentsAccess, @@ -226,38 +204,6 @@ private static object ToSandboxSettingsDiagnostic(SettingsData settings, string }; } - private static string DetectPreset(SettingsData settings) - { - if (MatchesPreset(settings, sandboxEnabled: true, allowOutbound: false, documents: null, downloads: null, desktop: null, clipboard: SandboxClipboardMode.None, timeoutMs: 30_000, maxOutputBytes: 4 * 1024 * 1024)) - return "LockedDown"; - if (MatchesPreset(settings, sandboxEnabled: true, allowOutbound: true, documents: SandboxFolderAccess.ReadOnly, downloads: SandboxFolderAccess.ReadOnly, desktop: SandboxFolderAccess.ReadOnly, clipboard: SandboxClipboardMode.Read, timeoutMs: 60_000, maxOutputBytes: 16 * 1024 * 1024)) - return "Balanced"; - if (MatchesPreset(settings, sandboxEnabled: true, allowOutbound: true, documents: SandboxFolderAccess.ReadWrite, downloads: SandboxFolderAccess.ReadWrite, desktop: SandboxFolderAccess.ReadWrite, clipboard: SandboxClipboardMode.Both, timeoutMs: 300_000, maxOutputBytes: 64 * 1024 * 1024)) - return "Permissive"; - return "Custom"; - } - - private static bool MatchesPreset( - SettingsData settings, - bool sandboxEnabled, - bool allowOutbound, - SandboxFolderAccess? documents, - SandboxFolderAccess? downloads, - SandboxFolderAccess? desktop, - SandboxClipboardMode clipboard, - int timeoutMs, - long maxOutputBytes) - { - return settings.SystemRunSandboxEnabled == sandboxEnabled - && settings.SystemRunAllowOutbound == allowOutbound - && settings.SandboxDocumentsAccess == documents - && settings.SandboxDownloadsAccess == downloads - && settings.SandboxDesktopAccess == desktop - && settings.SandboxClipboard == clipboard - && settings.SandboxTimeoutMs == timeoutMs - && settings.SandboxMaxOutputBytes == maxOutputBytes; - } - private void LogSandboxResult(SandboxExecutionResult result) { LogMxcDiagnostic( diff --git a/src/OpenClaw.Shared/Mxc/MxcConfig.cs b/src/OpenClaw.Shared/Mxc/MxcConfig.cs new file mode 100644 index 000000000..d7b690de5 --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcConfig.cs @@ -0,0 +1,171 @@ +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.Mxc; + +/// +/// POCO contract for the JSON config wxc-exec.exe consumes via +/// --config-base64 or --config <file>. Shape mirrors the +/// SDK's ContainerConfig (captured in tests/.../Mxc/Golden/*.json). +/// +public sealed record MxcConfig +{ + [JsonPropertyName("version")] + public string Version { get; init; } = "0.4.0-alpha"; + + [JsonPropertyName("containerId")] + public required string ContainerId { get; init; } + + [JsonPropertyName("containment")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Containment { get; init; } + + [JsonPropertyName("process")] + public required MxcProcess Process { get; init; } + + [JsonPropertyName("appContainer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MxcAppContainer? AppContainer { get; init; } + + [JsonPropertyName("filesystem")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MxcFilesystem? Filesystem { get; init; } + + [JsonPropertyName("network")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MxcNetwork? Network { get; init; } + + [JsonPropertyName("ui")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MxcUi? Ui { get; init; } + + [JsonPropertyName("lifecycle")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MxcLifecycle? Lifecycle { get; init; } +} + +public sealed record MxcProcess +{ + [JsonPropertyName("commandLine")] + public required string CommandLine { get; init; } + + [JsonPropertyName("cwd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Cwd { get; init; } + + [JsonPropertyName("env")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Env { get; init; } + + [JsonPropertyName("timeout")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? TimeoutMs { get; init; } +} + +public sealed record MxcAppContainer +{ + [JsonPropertyName("capabilities")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? Capabilities { get; init; } + + [JsonPropertyName("leastPrivilege")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LeastPrivilege { get; init; } + + [JsonPropertyName("ui")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MxcBaseProcessUi? Ui { get; init; } +} + +public sealed record MxcBaseProcessUi +{ + [JsonPropertyName("isolation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Isolation { get; init; } + + [JsonPropertyName("desktopSystemControl")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DesktopSystemControl { get; init; } + + [JsonPropertyName("systemSettings")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SystemSettings { get; init; } + + [JsonPropertyName("ime")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Ime { get; init; } +} + +public sealed record MxcFilesystem +{ + [JsonPropertyName("readonlyPaths")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? ReadonlyPaths { get; init; } + + [JsonPropertyName("readwritePaths")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? ReadwritePaths { get; init; } + + [JsonPropertyName("deniedPaths")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? DeniedPaths { get; init; } + + [JsonPropertyName("clearPolicyOnExit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ClearPolicyOnExit { get; init; } +} + +public sealed record MxcNetwork +{ + [JsonPropertyName("enforcementMode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EnforcementMode { get; init; } + + [JsonPropertyName("defaultPolicy")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DefaultPolicy { get; init; } + + [JsonPropertyName("allowedHosts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? AllowedHosts { get; init; } + + [JsonPropertyName("blockedHosts")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? BlockedHosts { get; init; } +} + +public sealed record MxcUi +{ + [JsonPropertyName("disable")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Disable { get; init; } + + [JsonPropertyName("clipboard")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Clipboard { get; init; } + + [JsonPropertyName("injection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Injection { get; init; } +} + +public sealed record MxcLifecycle +{ + [JsonPropertyName("destroyOnExit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DestroyOnExit { get; init; } + + [JsonPropertyName("preservePolicy")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? PreservePolicy { get; init; } +} + +/// Result returned by after running wxc-exec. +public sealed record MxcResult +{ + public bool Success { get; init; } + public int ExitCode { get; init; } + public string? Output { get; init; } + public string? Error { get; init; } + public bool TimedOut { get; init; } + public long DurationMs { get; init; } +} diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs new file mode 100644 index 000000000..f5b7c6fe2 --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs @@ -0,0 +1,453 @@ +using System.Text; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Pure function: + scratch directory → +/// for direct invocation of wxc-exec.exe. +/// +/// +/// What this class does: +/// +/// Translates (from the Sandbox page) and the +/// agent's request into the JSON shape wxc-exec consumes. +/// — grants every existing +/// $PATH directory as readonly so command-line tools (git, node, +/// python, ...) can be read from inside the sandbox. Drive roots skipped. +/// Scratch dir injection — adds the per-invocation scratch dir as +/// readwrite and forces TEMP/TMP/TMPDIR at it so +/// commands don't write to the user's real %TEMP%. +/// Cwd auto-grant — adds request.Cwd as readonly when not already +/// covered by an allow grant. AppContainer does NOT auto-grant cwd, so this +/// is required for commands to even start. +/// Defensive re-filter of allow lists against the deny list. +/// Shell command-line construction (cmd /S /C, powershell +/// -EncodedCommand). +/// +/// Env scrubbing happens upstream in SystemCapability.HandleRunAsync +/// via ExecEnvSanitizer.Sanitize; this class doesn't scrub env. +/// +public static class MxcConfigBuilder +{ + /// + /// Default per-process timeout when the caller doesn't supply one. + /// + public const int DefaultProcessTimeoutMs = 30_000; + + /// + /// Build the for a sandboxed invocation. + /// + /// Capability invocation request. + /// Per-invocation scratch directory the executor created. + /// Optional explicit container id (test/diagnostic use). Random GUID when null. + /// Optional override for the PATH env-var contents (test use). + public static MxcConfig Build( + SandboxExecutionRequest request, + string scratchDir, + string? containerId = null, + string? pathEnvVar = null) + { + if (request is null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir)); + + var policy = request.Policy; + var args = ParseSystemRunArgs(request.Args); + + // commandLine — shell-quoted. + var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv); + + // readonly = UI grants + every existing PATH dir (so tools like git, + // node, python can be read inside the sandbox). PATH drive roots are + // skipped by ResolvePathDirsForReadonly; shell startup roots are added + // explicitly below. + var roFromPolicy = (policy?.Filesystem?.ReadonlyPaths ?? Array.Empty()).ToList(); + var pathDirs = ResolvePathDirsForReadonly(pathEnvVar); + foreach (var dir in pathDirs) + if (!roFromPolicy.Contains(dir, StringComparer.OrdinalIgnoreCase)) + roFromPolicy.Add(dir); + + // readwrite = UI grants + scratch dir. + var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList(); + if (!rwFromPolicy.Contains(scratchDir, StringComparer.OrdinalIgnoreCase)) + rwFromPolicy.Add(scratchDir); + AddShellStartupDriveRoots(roFromPolicy, roFromPolicy.Concat(rwFromPolicy).ToArray()); + + // denied list from policy (settings dir, ~/.ssh, browser profiles, ...). + var denied = (policy?.Filesystem?.DeniedPaths ?? Array.Empty()).ToList(); + + // cwd auto-grant — AppContainer does not auto-grant the working + // directory. Give ungranted cwd read access so shells can start, but + // never silently upgrade it to write access; writes require an + // explicit readwrite folder grant. + if (!string.IsNullOrWhiteSpace(request.Cwd) + && !IsCoveredBy(request.Cwd, roFromPolicy) + && !IsCoveredBy(request.Cwd, rwFromPolicy)) + { + if (!OverlapsAny(request.Cwd, denied)) + roFromPolicy.Add(request.Cwd); + } + + // Deny wins: strip any allow that overlaps a deny after the merges above. + roFromPolicy = FilterOutDenied(roFromPolicy, denied); + rwFromPolicy = FilterOutDenied(rwFromPolicy, denied); + + // env — agent-supplied vars (already scrubbed upstream by + // ExecEnvSanitizer in SystemCapability) plus TEMP/TMP/TMPDIR forced + // to scratch. + var env = BuildEnv(request.Env, scratchDir, pathDirs); + + // timeout — caller-supplied or default. + var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : DefaultProcessTimeoutMs; + + // capabilities — only network for now. + var capabilities = new List(); + if (policy?.Network?.AllowOutbound == true) + capabilities.Add("internetClient"); + + var network = new MxcNetwork + { + DefaultPolicy = policy?.Network?.AllowOutbound == true ? "allow" : "block", + EnforcementMode = "capabilities", + }; + + var topLevelUi = new MxcUi + { + Disable = true, + Clipboard = MapClipboard(policy?.Ui?.Clipboard ?? ClipboardPolicy.None), + Injection = false, + }; + + var appContainerUi = new MxcBaseProcessUi + { + Isolation = "container", + DesktopSystemControl = false, + SystemSettings = "none", + Ime = false, + }; + + return new MxcConfig + { + Version = MxcPolicyBuilder.SupportedPolicyVersion, + ContainerId = containerId ?? Guid.NewGuid().ToString("N"), + // Top-level "containment" is intentionally omitted; the SDK doesn't + // emit it either. Isolation lives in appContainer.ui.isolation. + Process = new MxcProcess + { + CommandLine = commandLine, + Cwd = string.IsNullOrWhiteSpace(request.Cwd) ? null : request.Cwd, + Env = env, + TimeoutMs = timeoutMs, + }, + AppContainer = new MxcAppContainer + { + LeastPrivilege = false, + Capabilities = capabilities.ToArray(), + Ui = appContainerUi, + }, + Filesystem = new MxcFilesystem + { + ReadonlyPaths = roFromPolicy.ToArray(), + ReadwritePaths = rwFromPolicy.ToArray(), + DeniedPaths = denied.ToArray(), + // SDK output didn't include clearPolicyOnExit even when the + // input policy had it set, so we omit it here too. + ClearPolicyOnExit = null, + }, + Network = network, + Ui = topLevelUi, + Lifecycle = new MxcLifecycle + { + DestroyOnExit = true, + PreservePolicy = false, + }, + }; + } + + /// + /// Walk PATH and return each existing directory as a readonly grant + /// candidate. Drive roots (e.g. C:\) are skipped so a misconfigured + /// PATH entry can't grant the entire system drive. + /// + public static List ResolvePathDirsForReadonly(string? pathEnvVar = null) + { + var path = pathEnvVar ?? Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var pathDirs = path + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim().Trim('"')) + .Where(d => d.Length > 0) + .ToList(); + + var dirs = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var dir in pathDirs) + { + if (IsDriveRoot(dir)) continue; + try + { + if (!Directory.Exists(dir)) continue; + } + catch + { + continue; + } + if (seen.Add(dir)) dirs.Add(dir); + } + + return dirs; + } + + private static bool IsDriveRoot(string dir) + { + try + { + var root = Path.GetPathRoot(dir); + if (string.IsNullOrEmpty(root)) return false; + var trimmedDir = Path.TrimEndingDirectorySeparator(dir); + var trimmedRoot = Path.TrimEndingDirectorySeparator(root); + return string.Equals(trimmedDir, trimmedRoot, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static void AddShellStartupDriveRoots(List readonlyPaths, IEnumerable grantedPaths) + { + foreach (var path in grantedPaths) + AddDriveRoot(readonlyPaths, path); + + AddDriveRoot(readonlyPaths, Environment.GetFolderPath(Environment.SpecialFolder.Windows)); + AddDriveRoot(readonlyPaths, Environment.GetEnvironmentVariable("SystemDrive") ?? string.Empty); + } + + private static void AddDriveRoot(List readonlyPaths, string path) + { + string? root; + try { root = Path.GetPathRoot(Path.GetFullPath(path)); } + catch { return; } + + if (string.IsNullOrWhiteSpace(root)) + return; + + if (!readonlyPaths.Contains(root, StringComparer.OrdinalIgnoreCase)) + readonlyPaths.Add(root); + } + + /// + /// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit. + /// + /// + /// Env from the agent has already been scrubbed upstream in + /// SystemCapability.HandleRunAsync via + /// ExecEnvSanitizer.Sanitize (which rejects the whole command if + /// anything dangerous is present). We pass the surviving entries through + /// and force TEMP/TMP/TMPDIR to + /// so tools inside the sandbox don't write into the user's real %TEMP%. + /// + public static IReadOnlyList BuildEnv( + IReadOnlyDictionary? requestEnv, + string scratchDir, + IReadOnlyList? pathDirs = null) + { + // Windows env vars are case-insensitive — use OrdinalIgnoreCase so + // duplicate-case agent entries don't end up as separate strings. + var env = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (requestEnv is not null) + { + foreach (var (name, value) in requestEnv) + { + if (string.IsNullOrEmpty(name) || value is null) continue; + // Reject names with NUL/CR/LF/'=' so an agent can't smuggle + // a second KEY=VALUE pair into a single name field. + bool malformed = false; + foreach (var ch in name) + { + if (ch == '=' || ch == '\0' || ch == '\r' || ch == '\n') + { + malformed = true; + break; + } + } + if (malformed) continue; + env[name] = value; + } + } + + env["TEMP"] = scratchDir; + env["TMP"] = scratchDir; + env["TMPDIR"] = scratchDir; + if (pathDirs is { Count: > 0 }) + env["PATH"] = string.Join(Path.PathSeparator, pathDirs); + + return env.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList(); + } + + private static List FilterOutDenied(List allowed, List denied) + { + if (allowed.Count == 0 || denied.Count == 0) return allowed; + var normalizedDenied = denied + .Select(NormalizePath) + .Where(p => !string.IsNullOrEmpty(p)) + .ToList(); + return allowed + .Where(a => + { + var na = NormalizePath(a); + if (string.IsNullOrEmpty(na)) return false; + foreach (var d in normalizedDenied) + if (PathsOverlap(na, d)) return false; + return true; + }) + .ToList(); + } + + private static bool IsCoveredBy(string candidate, IEnumerable ancestors) + { + var nc = NormalizePath(candidate); + if (string.IsNullOrEmpty(nc)) return false; + foreach (var a in ancestors) + { + var na = NormalizePath(a); + if (string.IsNullOrEmpty(na)) continue; + if (IsSameOrNested(nc, na)) return true; + } + return false; + } + + private static bool OverlapsAny(string candidate, IEnumerable paths) + { + var nc = NormalizePath(candidate); + if (string.IsNullOrEmpty(nc)) return false; + foreach (var path in paths) + { + var np = NormalizePath(path); + if (string.IsNullOrEmpty(np)) continue; + if (PathsOverlap(nc, np)) return true; + } + return false; + } + + private static bool PathsOverlap(string left, string right) => + IsSameOrNested(left, right) || IsSameOrNested(right, left); + + private static bool IsSameOrNested(string path, string candidateParent) + { + if (string.Equals(path, candidateParent, StringComparison.OrdinalIgnoreCase)) return true; + return path.StartsWith(candidateParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(candidateParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + try { return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); } + catch { return path; } + } + + private static string MapClipboard(ClipboardPolicy mode) => mode switch + { + ClipboardPolicy.Read => "read", + ClipboardPolicy.Write => "write", + ClipboardPolicy.All => "all", + _ => "none", + }; + + /// + /// Capability args envelope for system.run. Other capability shapes can add + /// their own parser here as they're rehosted. + /// + private static SystemRunArgs ParseSystemRunArgs(System.Text.Json.JsonElement args) + { + if (args.ValueKind != System.Text.Json.JsonValueKind.Object) + return new SystemRunArgs("", "powershell", Array.Empty()); + + string command = args.TryGetProperty("command", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String + ? (c.GetString() ?? "") : ""; + string shell = args.TryGetProperty("shell", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String + ? (s.GetString() ?? "powershell") : "powershell"; + string[] argv = Array.Empty(); + if (args.TryGetProperty("args", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.Array) + { + argv = a.EnumerateArray() + .Where(e => e.ValueKind == System.Text.Json.JsonValueKind.String) + .Select(e => e.GetString() ?? "") + .ToArray(); + } + return new SystemRunArgs(command, shell, argv); + } + + private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv); +} + +/// +/// Shell command-line construction for the sandboxed payload — wraps the +/// agent's command in cmd.exe /S /C "..." or +/// powershell.exe -EncodedCommand <utf16le-base64> so it can be +/// passed verbatim to CreateProcessW inside the AppContainer. +/// +internal static class ShellCommandLine +{ + public static string Build(string shell, string command, IReadOnlyList argv) + { + var normalized = (shell ?? "powershell").Trim().ToLowerInvariant(); + return normalized switch + { + "cmd" => BuildCmd(command, argv), + "pwsh" or "powershell" => BuildPowershell(normalized == "pwsh" ? "pwsh.exe" : "powershell.exe", command, argv), + _ => BuildPowershell("powershell.exe", command, argv), + }; + } + + private static string BuildCmd(string command, IReadOnlyList argv) + { + // cmd /S /C " [args]" — /S strips outer quotes so cmd treats + // everything after /C as the command line verbatim. + var sb = new StringBuilder("cmd.exe /S /C \""); + sb.Append(command); + foreach (var a in argv) + { + sb.Append(' '); + sb.Append(QuoteForCmd(a)); + } + sb.Append('"'); + return sb.ToString(); + } + + private static string BuildPowershell(string exe, string command, IReadOnlyList argv) + { + // -EncodedCommand avoids quoting pitfalls entirely. + // We concatenate command + argv with spaces and let powershell parse it. + var sb = new StringBuilder(command); + foreach (var a in argv) + { + sb.Append(' '); + sb.Append(QuoteForPowershell(a)); + } + var script = sb.ToString(); + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + return $"{exe} -NoProfile -NonInteractive -EncodedCommand {encoded}"; + } + + private static string QuoteForCmd(string arg) + { + // Note: `%VAR%` env-var expansion inside `cmd /S /C "..."` cannot be + // reliably suppressed via quoting (cmd parses % before applying quote + // rules). The cmd shell route is opt-in and runs inside the AppContainer + // with a controlled env, so the expansion target is sandbox-side, not + // host-side. Callers wanting verbatim arguments should use powershell + // (-EncodedCommand) which has no env-expansion ambiguity. + if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '"', '&', '|', '<', '>', '^', '(', ')', '%' }) < 0) + return arg; + return "\"" + arg.Replace("\"", "\"\"") + "\""; + } + + private static string QuoteForPowershell(string arg) + { + if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '\'', '"', '`', '$' }) < 0) + return arg; + return "'" + arg.Replace("'", "''") + "'"; + } +} diff --git a/src/OpenClaw.Shared/Mxc/MxcExecutor.cs b/src/OpenClaw.Shared/Mxc/MxcExecutor.cs new file mode 100644 index 000000000..e19c92d3f --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcExecutor.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Runs commands inside a Windows AppContainer via wxc-exec.exe. Throws +/// on construction if the binary is absent. +/// +public sealed class MxcExecutor +{ + private const int DefaultStdoutCapBytes = 40_000; + private const int DefaultStderrCapBytes = 5_000; + + private readonly string _wxcExePath; + private readonly int _stdoutCapBytes; + private readonly int _stderrCapBytes; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public MxcExecutor(string wxcExePath, int? stdoutCapBytes = null, int? stderrCapBytes = null) + { + if (string.IsNullOrEmpty(wxcExePath)) throw new ArgumentException("wxcExePath required", nameof(wxcExePath)); + if (!File.Exists(wxcExePath)) + throw new FileNotFoundException($"wxc-exec.exe not found at: {wxcExePath}", wxcExePath); + _wxcExePath = wxcExePath; + _stdoutCapBytes = stdoutCapBytes is > 0 ? stdoutCapBytes.Value : DefaultStdoutCapBytes; + _stderrCapBytes = stderrCapBytes is > 0 ? stderrCapBytes.Value : DefaultStderrCapBytes; + } + + public async Task RunAsync( + MxcConfig config, + CancellationToken ct = default, + bool experimental = false, + string? workingDirectory = null) + { + var json = JsonSerializer.Serialize(config, s_jsonOptions); + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + var args = new List(); + if (experimental) args.Add("--experimental"); + args.Add("--config-base64"); + args.Add(base64); + return await RunWithArgumentsAsync(args, ct, workingDirectory); + } + + /// + /// Additive (OpenClaw): runs wxc-exec with --config <file> instead of + /// --config-base64. Use when the serialized config approaches the Windows + /// command-line limit (~32k chars). Caller owns the file lifetime. + /// + public Task RunWithConfigFileAsync( + string configFilePath, + CancellationToken ct = default, + bool experimental = false, + string? workingDirectory = null) + { + if (string.IsNullOrEmpty(configFilePath)) throw new ArgumentException("configFilePath required", nameof(configFilePath)); + // Reject embedded quotes to avoid any argv-parsing ambiguity. NTFS allows + // names with most punctuation but disallows '"', so this is also a + // guard against malformed input rather than a real-world rejection. + if (configFilePath.IndexOf('"') >= 0) + throw new ArgumentException("configFilePath must not contain quote characters", nameof(configFilePath)); + var args = new List(); + if (experimental) args.Add("--experimental"); + args.Add("--config"); + args.Add(configFilePath); + return RunWithArgumentsAsync(args, ct, workingDirectory); + } + + private async Task RunWithArgumentsAsync( + IReadOnlyList arguments, + CancellationToken ct, + string? workingDirectory) + { + using var process = new Process(); + var startInfo = new ProcessStartInfo + { + FileName = _wxcExePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + if (!string.IsNullOrWhiteSpace(workingDirectory)) + startInfo.WorkingDirectory = workingDirectory; + // ArgumentList avoids the manual-quoting trap that bites Process.Arguments + // (each entry is escaped per Win32 CommandLineToArgvW rules by the BCL). + foreach (var arg in arguments) startInfo.ArgumentList.Add(arg); + process.StartInfo = startInfo; + + var stdoutBuilder = new StringBuilder(); + var stderrBuilder = new StringBuilder(); + // StringBuilder is not thread-safe; the async event handlers can fire + // concurrently with each other and with the post-kill ToString() read. + var outLock = new object(); + var errLock = new object(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data is null) return; + lock (outLock) + { + if (stdoutBuilder.Length < _stdoutCapBytes * 2) + stdoutBuilder.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is null) return; + lock (errLock) + { + if (stderrBuilder.Length < _stderrCapBytes * 2) + stderrBuilder.AppendLine(e.Data); + } + }; + + var sw = Stopwatch.StartNew(); + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + bool completed; + try + { + await process.WaitForExitAsync(ct); + completed = true; + } + catch (OperationCanceledException) + { + completed = false; + } + + if (!completed) + { + try { process.Kill(entireProcessTree: true); } catch { } + // WaitForExit() (sync) blocks until both stdout and stderr async + // readers have drained the redirected pipes. Without this the + // event handlers can still be appending while ToString() runs. + try { process.WaitForExit(); } catch { } + sw.Stop(); + string capturedOut; + lock (outLock) { capturedOut = stdoutBuilder.ToString(); } + return new MxcResult + { + Success = false, + ExitCode = -1, + Output = Truncate(capturedOut, _stdoutCapBytes), + Error = "Execution was cancelled.", + TimedOut = true, + DurationMs = sw.ElapsedMilliseconds, + }; + } + + // Flush async readers before reading the StringBuilders. + try { process.WaitForExit(); } catch { } + sw.Stop(); + string outRaw, errRaw; + lock (outLock) { outRaw = stdoutBuilder.ToString().Trim(); } + lock (errLock) { errRaw = stderrBuilder.ToString().Trim(); } + var stdout = Truncate(outRaw, _stdoutCapBytes); + var stderr = Truncate(errRaw, _stderrCapBytes); + + return new MxcResult + { + Success = process.ExitCode == 0, + ExitCode = process.ExitCode, + Output = string.IsNullOrEmpty(stdout) ? null : stdout, + Error = string.IsNullOrEmpty(stderr) ? null : stderr, + TimedOut = false, + DurationMs = sw.ElapsedMilliseconds, + }; + } + catch (Exception ex) + { + sw.Stop(); + return new MxcResult + { + Success = false, + ExitCode = -1, + Error = $"Failed to launch wxc-exec.exe: {ex.Message}", + DurationMs = sw.ElapsedMilliseconds, + }; + } + } + + private static string Truncate(string text, int maxLength) + { + if (text.Length <= maxLength) return text; + return text[..maxLength] + $"\n\n... [TRUNCATED — showing first {maxLength} of {text.Length} chars]"; + } +} diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs index 721cc4a8d..2a001d1e2 100644 --- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -10,11 +10,11 @@ namespace OpenClaw.Shared.Mxc; /// Policy decisions: /// /// readonlyPaths — populated from user-granted folders (Documents, -/// Downloads, Desktop, custom). The Node bridge additionally merges PATH-specific -/// tool directories so spawned shells can find git/node/python/etc. -/// readwritePaths — user-granted read+write folders. The Node bridge -/// adds a per-invocation scratch directory and rewrites TEMP/TMP/TMPDIR to point -/// at it, so the user's real %TEMP% stays out of reach. +/// Downloads, Desktop, custom). The MXC config builder additionally merges +/// PATH-specific tool directories so spawned shells can find git/node/python/etc. +/// readwritePaths — user-granted read+write folders. The MXC config +/// builder adds a per-invocation scratch directory and rewrites TEMP/TMP/TMPDIR +/// to point at it, so the user's real %TEMP% stays out of reach. /// deniedPaths — settings directory (protect MCP token, gateway /// credentials, ElevenLabs key), ~/.ssh, and the common browser profile /// roots (Chrome / Edge / Firefox / Brave). Always blocked regardless of grants. @@ -25,8 +25,9 @@ namespace OpenClaw.Shared.Mxc; public static class MxcPolicyBuilder { /// - /// Policy schema version. Per the @microsoft/mxc-sdk validator, this must be - /// in the supported range (currently MIN 0.4.0-alpha, SUPPORTED 0.5.0-alpha). + /// Policy schema version. @microsoft/mxc-sdk 0.1.8 accepts 0.4.0-alpha + /// through 0.5.0-alpha; keep the documented 0.4.0-alpha schema until we + /// intentionally adopt a newer MXC policy contract. /// public const string SupportedPolicyVersion = "0.4.0-alpha"; diff --git a/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs deleted file mode 100644 index 4e92a0cba..000000000 --- a/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs +++ /dev/null @@ -1,344 +0,0 @@ -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace OpenClaw.Shared.Mxc; - -/// -/// Implements by spawning node.exe with -/// tools/mxc/run-command.cjs, which calls -/// @microsoft/mxc-sdk.spawnSandboxFromConfig({usePty:false}) to run the -/// payload inside a one-shot AppContainer. -/// -public sealed class OneShotAppContainerExecutor : ISandboxExecutor -{ - public string Name => "mxc-oneshot-appc"; - public bool IsContained => true; - - private readonly MxcAvailability _availability; - private readonly string _runCommandScriptPath; - private readonly string _nodeExecutablePath; - private readonly IOpenClawLogger _logger; - - /// Default cap on stdout/stderr returned to the host (4 MiB). - public const long DefaultMaxOutputBytes = 4 * 1024 * 1024; - - /// - /// Optional environment variable override for the Node executable used by the - /// runner. Falls back to node.exe on PATH. - /// - public const string NodeExecutableOverrideEnvVar = "OPENCLAW_NODE_EXEC"; - - public OneShotAppContainerExecutor( - MxcAvailability availability, - string runCommandScriptPath, - IOpenClawLogger? logger = null, - string? nodeExecutableOverride = null) - { - _availability = availability; - _runCommandScriptPath = runCommandScriptPath; - _logger = logger ?? NullLogger.Instance; - _nodeExecutablePath = nodeExecutableOverride - ?? Environment.GetEnvironmentVariable(NodeExecutableOverrideEnvVar) - ?? ResolveExecutableOnPath("node.exe") - ?? "node.exe"; - } - - public async Task ExecuteAsync( - SandboxExecutionRequest request, - CancellationToken ct = default) - { - if (!_availability.IsAppContainerAvailable) - throw new SandboxUnavailableException( - _availability.UnsupportedReasons.FirstOrDefault() ?? "AppContainer unavailable"); - - if (!_availability.IsWxcExecResolvable) - throw new SandboxUnavailableException("wxc-exec.exe not found"); - - if (!File.Exists(_runCommandScriptPath)) - throw new SandboxUnavailableException( - $"run-command.cjs not found at {_runCommandScriptPath}"); - - // Per-request output cap. Default applies only when the caller doesn't - // pass one. Used to be baked at construction; that caused stale floors - // when the user lowered SandboxMaxOutputBytes after the executor was - // built (Math.Max(stale, new) kept the larger old value). - var capBytes = request.MaxOutputBytes is > 0 ? request.MaxOutputBytes.Value : DefaultMaxOutputBytes; - - var bridgeRequest = new BridgeRequest( - CapabilityCommand: request.CapabilityCommand, - Args: request.Args, - Policy: request.Policy, - Cwd: request.Cwd, - Env: request.Env, - TimeoutMs: request.TimeoutMs, - MaxOutputBytes: capBytes, - WxcExecPath: _availability.WxcExecPath); - - var requestJson = JsonSerializer.Serialize(bridgeRequest, BridgeJson); - LogDiagnostic( - "[mxc] bridge request prepared " + - $"node={_nodeExecutablePath}; script={_runCommandScriptPath}; " + - $"wxcExec={_availability.WxcExecPath ?? ""}; timeoutMs={request.TimeoutMs}; " + - $"maxOutputBytes={capBytes}; cwd={request.Cwd ?? ""}; " + - $"envKeys=[{string.Join(",", request.Env?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty())}]; " + - $"requestBytes={Encoding.UTF8.GetByteCount(requestJson)}"); - - var psi = new ProcessStartInfo - { - FileName = _nodeExecutablePath, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardOutputEncoding = Encoding.UTF8, - StandardErrorEncoding = Encoding.UTF8, - }; - psi.ArgumentList.Add(_runCommandScriptPath); - - var sw = Stopwatch.StartNew(); - using var process = new Process { StartInfo = psi }; - - try - { - process.Start(); - LogDiagnostic($"[mxc] bridge process started pid={process.Id}"); - } - catch (Exception ex) - { - throw new SandboxUnavailableException( - $"Failed to start node.exe at '{_nodeExecutablePath}': {ex.Message}", ex); - } - - // Caller-controlled timeout governs how long the bridge has to return. - // Add a small grace so the bridge can clean up before we kill it. - var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs + 5000 : 0; - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - if (timeoutMs > 0) - cts.CancelAfter(timeoutMs); - - try - { - await process.StandardInput.WriteAsync(requestJson.AsMemory(), cts.Token); - await process.StandardInput.FlushAsync(cts.Token); - process.StandardInput.Close(); - LogDiagnostic($"[mxc] bridge request written pid={process.Id}; bytes={Encoding.UTF8.GetByteCount(requestJson)}"); - } - catch (OperationCanceledException) - { - // Either the caller cancelled or the timeout fired — in both cases - // the spawned node + sandboxed payload must be killed so we don't - // leak processes after the host gives up. - KillProcessTree(process); - throw; - } - - // Envelope cap: caller-cap covers stdout AND stderr each. Allow up to - // 2× that plus envelope/JSON overhead so a worst-case bridge response - // (large stdout + large stderr) still fits without truncation. - var envelopeCap = (capBytes * 2L) + (256L * 1024L); - - var stdoutTask = ReadCappedAsync(process.StandardOutput, envelopeCap, cts.Token); - var stderrTask = ReadCappedAsync(process.StandardError, envelopeCap, cts.Token); - - bool timedOut = false; - try - { - await process.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - // ALWAYS kill the process tree on cancellation, whether the source is - // the caller's CancellationToken (agent abort) or our local timeout. - // Without this the sandboxed payload keeps running after we return. - KillProcessTree(process); - - // Distinguish caller cancel from local-timeout for the return path. - if (!ct.IsCancellationRequested) - timedOut = true; - else - throw; - } - - var stdout = await stdoutTask; - var stderr = await stderrTask; - - sw.Stop(); - if (!string.IsNullOrWhiteSpace(stderr)) - LogDiagnostic($"[mxc] bridge diagnostics pid={SafeProcessId(process)}; stderr={Truncate(stderr, 4000)}"); - LogDiagnostic( - "[mxc] bridge process completed " + - $"pid={SafeProcessId(process)}; exitCode={(process.HasExited ? process.ExitCode : -1)}; " + - $"durationMs={sw.ElapsedMilliseconds}; timedOut={timedOut}; " + - $"stdoutChars={stdout.Length}; stderrChars={stderr.Length}"); - - if (timedOut) - { - return new SandboxExecutionResult( - ExitCode: -1, - Stdout: stdout, - Stderr: stderr.Length > 0 ? stderr : "Sandboxed invocation timed out.", - TimedOut: true, - DurationMs: sw.ElapsedMilliseconds, - ContainmentTag: "mxc", - StructuredResult: null); - } - - // Bridge writes a single JSON envelope to stdout on completion. - if (TryParseBridgeResponse(stdout, out var response)) - { - LogDiagnostic( - "[mxc] bridge response parsed " + - $"exitCode={response.ExitCode}; timedOut={response.TimedOut}; " + - $"durationMs={response.DurationMs}; containment={response.ContainmentTag ?? "mxc"}; " + - $"stdoutChars={response.Stdout?.Length ?? 0}; stderrChars={response.Stderr?.Length ?? 0}; " + - $"structured={response.StructuredResult.HasValue}"); - return new SandboxExecutionResult( - ExitCode: response.ExitCode, - Stdout: response.Stdout, - Stderr: response.Stderr, - TimedOut: response.TimedOut, - DurationMs: response.DurationMs == 0 ? sw.ElapsedMilliseconds : response.DurationMs, - ContainmentTag: response.ContainmentTag ?? "mxc", - StructuredResult: response.StructuredResult); - } - - // Bridge crashed or returned malformed output. Surface as a sandbox failure - // — node-side stderr likely has the diagnostic. - _logger.Warn($"[mxc] bridge returned malformed output ({stdout.Length} bytes); stderr={Truncate(stderr, 200)}"); - return new SandboxExecutionResult( - ExitCode: process.ExitCode, - Stdout: stdout, - Stderr: stderr, - TimedOut: false, - DurationMs: sw.ElapsedMilliseconds, - ContainmentTag: "mxc", - StructuredResult: null); - } - - private static async Task ReadCappedAsync(StreamReader reader, long maxBytes, CancellationToken ct) - { - var sb = new StringBuilder(); - var buffer = new char[8192]; - long bytesRead = 0; - while (true) - { - int read; - try { read = await reader.ReadAsync(buffer, ct); } - catch (OperationCanceledException) { break; } - catch (IOException) { break; } - - if (read == 0) - break; - - // Approximate cap: chars × 2 bytes upper bound for UTF-16. - bytesRead += read * 2; - sb.Append(buffer, 0, read); - if (bytesRead >= maxBytes) - { - sb.Append("\n[output truncated]"); - break; - } - } - return sb.ToString(); - } - - private static void KillProcessTree(Process process) - { - try - { - if (!process.HasExited) - process.Kill(entireProcessTree: true); - } - catch { /* best-effort */ } - } - - private void LogDiagnostic(string message) - { - _logger.Debug(message); - Trace.WriteLine(message); - } - - private static int SafeProcessId(Process process) - { - try { return process.Id; } - catch { return -1; } - } - - private static string? ResolveExecutableOnPath(string fileName) - { - var path = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(path)) - return null; - - foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - try - { - var candidate = Path.Combine(directory.Trim(), fileName); - if (File.Exists(candidate)) - return candidate; - } - catch - { - // Ignore malformed PATH entries. - } - } - - return null; - } - - private static bool TryParseBridgeResponse(string json, out BridgeResponse response) - { - response = default!; - if (string.IsNullOrWhiteSpace(json)) - return false; - try - { - response = JsonSerializer.Deserialize(json.Trim(), BridgeJson)!; - return response is not null; - } - catch (JsonException) - { - return false; - } - } - - private static string Truncate(string s, int max) => - s.Length <= max ? s : string.Concat(s.AsSpan(0, max), "…"); - - private static readonly JsonSerializerOptions BridgeJson = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = - { - // Enums must serialize as camelCase strings so @microsoft/mxc-sdk - // (which expects "none" / "read" / "write" / "all") accepts them. - new System.Text.Json.Serialization.JsonStringEnumConverter( - System.Text.Json.JsonNamingPolicy.CamelCase), - }, - }; - - private sealed record BridgeRequest( - string CapabilityCommand, - JsonElement Args, - SandboxPolicy Policy, - string? Cwd, - IReadOnlyDictionary? Env, - int TimeoutMs, - long MaxOutputBytes, - string? WxcExecPath); - - private sealed record BridgeResponse( - int ExitCode, - string Stdout, - string Stderr, - bool TimedOut, - long DurationMs, - string? ContainmentTag, - JsonElement? StructuredResult); -} diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs index d78d1680a..c9a25c1e9 100644 --- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -4,7 +4,7 @@ namespace OpenClaw.Shared.Mxc; /// Cross-platform sandbox policy expressing what a contained payload can access. /// Mirrors the SandboxPolicy shape from @microsoft/mxc-sdk's /// TypeScript types (see microsoft/mxc/sdk/src/types.ts). C# representation -/// so we can build policy without going through the Node bridge. +/// so we can build policy for direct wxc-exec.exe invocation. /// public sealed record SandboxPolicy( string Version, @@ -40,13 +40,14 @@ public enum ClipboardPolicy /// /// When is true, system.run -/// is contained via MXC AppContainer. When MXC is unavailable on the host, the call -/// is denied (no fallback). When the toggle is false, system.run runs on the -/// host as before. +/// is contained via MXC AppContainer. When MXC is unavailable on the host, system.run +/// falls back to the host runner with a warning so older Windows builds are not +/// completely blocked. When the toggle is false, system.run runs on the host +/// as before. /// public enum SandboxMode { - /// Sandbox required; fail-closed if unavailable. + /// Use MXC when available; otherwise fall back uncontained with a warning. Enabled, /// Bypass MXC entirely. diff --git a/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs deleted file mode 100644 index 953d45a5f..000000000 --- a/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace OpenClaw.Shared.Mxc; - -/// -/// implementation that always throws -/// . Used when MXC is not installed -/// on the host so can still honor the -/// toggle: when sandbox -/// is enabled and MXC is absent, the invocation is denied (fail-closed) -/// rather than silently routed to the host. -/// -public sealed class UnavailableSandboxExecutor : ISandboxExecutor -{ - public string Name => "mxc-unavailable"; - public bool IsContained => false; - - private readonly string _reason; - - public UnavailableSandboxExecutor(string reason) - { - _reason = reason; - } - - public Task ExecuteAsync( - SandboxExecutionRequest request, - CancellationToken ct = default) - { - throw new SandboxUnavailableException(_reason); - } -} diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 4cf9caa75..9c86ac5a9 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -22,7 +22,6 @@ using System.IO; using System.IO.Pipes; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Threading; @@ -279,7 +278,7 @@ public IntPtr GetHubWindowHandle() ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); private static readonly string CrashLogPath = Path.Combine(DataPath, "crash.log"); - private static readonly string RunMarkerPath = Path.Combine(DataPath, "run.marker"); + private static readonly AppRunMarker s_runMarker = new(Path.Combine(DataPath, "run.marker")); public App() { @@ -297,8 +296,8 @@ public App() InitializeComponent(); - CheckPreviousRun(); - MarkRunStarted(); + s_runMarker.Check(); + s_runMarker.MarkStarted(); // Hook up crash handlers this.UnhandledException += OnUnhandledException; @@ -326,7 +325,7 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv private void OnProcessExit(object? sender, EventArgs e) { - MarkRunEnded(); + s_runMarker.MarkEnded(); try { Logger.Info($"Process exiting (ExitCode={Environment.ExitCode})"); @@ -361,192 +360,6 @@ private static void LogCrash(string source, Exception? ex) catch { /* Ignore logging failures */ } } - // ----------------------------------------------------------------------- - // CLI uninstall path - // Invoked when --uninstall is present in argv. Runs headlessly without - // creating the tray UI. Attaches to the parent console so stdout/stderr - // are visible when invoked from PowerShell or cmd. - // ----------------------------------------------------------------------- - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool AttachConsole(int dwProcessId); - - private const int AttachParentProcess = -1; - - private static async Task RunCliUninstallAsync(string[] args) - { - // Attach to parent console so output is visible when invoked from - // PowerShell or cmd. Fails silently if no parent console exists. - AttachConsole(AttachParentProcess); - - bool dryRun = args.Contains("--dry-run", StringComparer.OrdinalIgnoreCase); - bool confirmDestructive = args.Contains("--confirm-destructive", StringComparer.OrdinalIgnoreCase); - - // Locate --json-output argument - string? jsonOutputPath = null; - for (int i = 0; i < args.Length - 1; i++) - { - if (string.Equals(args[i], "--json-output", StringComparison.OrdinalIgnoreCase)) - { - jsonOutputPath = args[i + 1]; - break; - } - } - - if (!confirmDestructive && !dryRun) - { - Console.Error.WriteLine( - "ERROR: --uninstall requires --confirm-destructive (or --dry-run)."); - Environment.Exit(2); - return; - } - - var settings = new SettingsManager(); - var engine = LocalGatewayUninstall.Build(settings, logger: new AppLogger()); - - LocalGatewayUninstallResult result; - try - { - result = await engine.RunAsync(new LocalGatewayUninstallOptions - { - DryRun = dryRun, - ConfirmDestructive = confirmDestructive - }); - } - catch (Exception ex) - { - Console.Error.WriteLine($"ERROR: Uninstall engine threw: {ex.Message}"); - Environment.Exit(1); - return; - } - - // Human-readable summary (tokens already redacted inside engine steps) - Console.WriteLine("OpenClaw Local Gateway Uninstall"); - Console.WriteLine($"DryRun: {dryRun}"); - Console.WriteLine($"Success: {result.Success}"); - Console.WriteLine($"Steps: {result.Steps.Count} ({result.SkippedSteps.Count} skipped)"); - Console.WriteLine($"Errors: {result.Errors.Count}"); - foreach (var e in result.Errors) - Console.Error.WriteLine($" ERROR: {CliRedact(e)}"); - Console.WriteLine("Postconditions:"); - Console.WriteLine($" WslDistroAbsent: {result.Postconditions.WslDistroAbsent}"); - Console.WriteLine($" AutostartCleared: {result.Postconditions.AutostartCleared}"); - Console.WriteLine($" SetupStateAbsent: {result.Postconditions.SetupStateAbsent}"); - Console.WriteLine($" DeviceTokenCleared: {result.Postconditions.DeviceTokenCleared}"); - Console.WriteLine($" McpTokenPreserved: {result.Postconditions.McpTokenPreserved}"); - Console.WriteLine($" KeepalivesAbsent: {result.Postconditions.KeepalivesAbsent}"); - Console.WriteLine($" VhdDirAbsent: {result.Postconditions.VhdDirAbsent}"); - Console.WriteLine($" LocalGatewayRecordsAbsent: {result.Postconditions.LocalGatewayRecordsAbsent}"); - Console.WriteLine($" LocalGatewayIdentityDirsAbsent: {result.Postconditions.LocalGatewayIdentityDirsAbsent}"); - - // JSON output — redaction applied to step details and error strings - if (jsonOutputPath != null) - { - try - { - var dir = Path.GetDirectoryName(jsonOutputPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var payload = new - { - success = result.Success, - dry_run = dryRun, - steps = result.Steps.Select(s => new - { - name = s.Name, - status = s.Status.ToString(), - detail = CliRedact(s.Detail) - }), - errors = result.Errors.Select(CliRedact), - skipped_steps = result.SkippedSteps, - postconditions = new - { - wsl_distro_absent = result.Postconditions.WslDistroAbsent, - autostart_cleared = result.Postconditions.AutostartCleared, - setup_state_absent = result.Postconditions.SetupStateAbsent, - device_token_cleared = result.Postconditions.DeviceTokenCleared, - mcp_token_preserved = result.Postconditions.McpTokenPreserved, - keepalives_absent = result.Postconditions.KeepalivesAbsent, - vhd_dir_absent = result.Postconditions.VhdDirAbsent, - local_gateway_records_absent = result.Postconditions.LocalGatewayRecordsAbsent, - local_gateway_identity_dirs_absent = result.Postconditions.LocalGatewayIdentityDirsAbsent - } - }; - - File.WriteAllText(jsonOutputPath, JsonSerializer.Serialize( - payload, new JsonSerializerOptions { WriteIndented = true })); - - Console.WriteLine($"JSON result: {jsonOutputPath}"); - } - catch (Exception ex) - { - Console.Error.WriteLine( - $"WARNING: Failed to write JSON output to '{jsonOutputPath}': {ex.Message}"); - } - } - - Environment.Exit(result.Success ? 0 : 1); - } - - /// - /// Redacts token/key material from a string before writing it to CLI - /// stdout or a JSON output file. Mirrors the PowerShell Invoke-Redact - /// pattern in validate-wsl-gateway-uninstall.ps1. - /// - private static string? CliRedact(string? value) - { - if (string.IsNullOrEmpty(value)) return value; - // Redact JSON field values for known secret fields. - value = System.Text.RegularExpressions.Regex.Replace( - value, - @"(""(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)""\s*:\s*"")[^""]+("")", - "$1$2"); - // Redact bare key=value / key: value patterns. - value = System.Text.RegularExpressions.Regex.Replace( - value, - @"(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,""'}{]+", - "$1"); - return value; - } - - private static void CheckPreviousRun() - { - try - { - if (File.Exists(RunMarkerPath)) - { - var startedAt = File.ReadAllText(RunMarkerPath); - Logger.Error($"Previous session did not exit cleanly (started {startedAt})"); - File.Delete(RunMarkerPath); - } - } - catch { } - } - - private static void MarkRunStarted() - { - try - { - var dir = Path.GetDirectoryName(RunMarkerPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - File.WriteAllText(RunMarkerPath, DateTime.Now.ToString("O")); - } - catch { } - } - - private static void MarkRunEnded() - { - try - { - if (File.Exists(RunMarkerPath)) - File.Delete(RunMarkerPath); - } - catch { } - } - private void OnUiThread(Microsoft.UI.Dispatching.DispatcherQueueHandler action) => _dispatcherQueue?.TryEnqueue(action); /// @@ -582,7 +395,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // ----------------------------------------------------------------------- if (_startupArgs.Contains("--uninstall", StringComparer.OrdinalIgnoreCase)) { - await RunCliUninstallAsync(_startupArgs); + await CliUninstallHandler.RunAsync(_startupArgs); return; // Environment.Exit called inside; defensive return } @@ -2129,30 +1942,6 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) catch { /* ignore */ } } } - - private void OnRecordingStateChanged(object? sender, RecordingStateEventArgs args) - { - var source = args.Type == RecordingType.Screen ? "Screen" : "Camera"; - if (args.IsActive) - { - var title = args.Type == RecordingType.Screen - ? LocalizationHelper.GetString("Activity_ScreenRecordingStarted") - : LocalizationHelper.GetString("Activity_CameraRecordingStarted"); - var duration = args.DurationMs > 0 ? $" ({args.DurationMs / 1000.0:0.#}s)" : ""; - AddRecentActivity($"{title}{duration}", category: "node", - icon: "🔴", - details: string.Format(LocalizationHelper.GetString("Activity_RecordingRequestedByAgent"), source)); - } - else - { - var title = args.Type == RecordingType.Screen - ? LocalizationHelper.GetString("Activity_ScreenRecordingComplete") - : LocalizationHelper.GetString("Activity_CameraRecordingComplete"); - AddRecentActivity(title, category: "node", - icon: "✅", - details: string.Format(LocalizationHelper.GetString("Activity_RecordingSentToAgent"), source)); - } - } private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args) { @@ -3390,29 +3179,64 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) private static UpdateCommandCenterInfo BuildInitialUpdateInfo() => new() { Status = "Not checked", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown" + CurrentVersion = AppVersionInfo.Version }; - private async Task CheckForUpdatesAsync() + // Cross-path concurrency for update checks, split into two phases: + // - _updateCheckGate: held only during the metadata/network check. + // Short timeout so contended callers don't block on user thinking. + // - _updateInstallInProgress: Interlocked flag covering the user-facing + // UpdateDialog + download + install. Prevents two parallel installs + // without holding a lock across user interaction. + private readonly System.Threading.SemaphoreSlim _updateCheckGate = new(1, 1); + private int _updateInstallInProgress; + + private async Task CheckForUpdatesAsync(bool userInitiated = false) { - try + // === Stage 1: metadata check (gate-protected) === + if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30))) { + Logger.Warn("Update check gate timed out: another check is in progress"); + if (_appState != null) + { + _appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "another update check is already in progress; try again in a moment" + }; + } + return true; // Don't block launch + } + #if DEBUG + try + { Logger.Info("Skipping update check in debug build"); _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Skipped", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionInfo.Version, CheckedAt = DateTime.UtcNow, Detail = "debug build" }; return true; + } + finally + { + _updateCheckGate.Release(); + } #else + string releaseTag; + string changelog; + try + { Logger.Info("Checking for updates..."); _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Checking", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionInfo.Version, CheckedAt = DateTime.UtcNow }; var updateFound = await AppUpdater.CheckForUpdatesAsync(); @@ -3423,7 +3247,7 @@ private async Task CheckForUpdatesAsync() _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Current", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionInfo.Version, CheckedAt = DateTime.UtcNow, Detail = "no updates available" }; @@ -3431,72 +3255,330 @@ private async Task CheckForUpdatesAsync() } var release = AppUpdater.LatestRelease!; - var changelog = AppUpdater.GetChangelog(true) ?? "No release notes available."; - Logger.Info($"Update available: {release.TagName}"); + if (string.IsNullOrEmpty(release.TagName)) + { + // Defensive: AppUpdater says an update is available but the + // release has no tag. Don't silently claim "up to date" — + // surface as Failed so the user sees something is off. + Logger.Warn("Update reported available but release has no TagName"); + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "update metadata incomplete (missing version tag)" + }; + return true; + } + + releaseTag = release.TagName; + changelog = AppUpdater.GetChangelog(true) ?? "No release notes available."; + Logger.Info($"Update available: {releaseTag}"); _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Available", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - LatestVersion = release.TagName, + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, CheckedAt = DateTime.UtcNow, Detail = "prompted" }; if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) && - string.Equals(_settings.SkippedUpdateTag, release.TagName, StringComparison.OrdinalIgnoreCase)) + string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase) && + !userInitiated) { - Logger.Info($"Skipping update prompt for remembered version {release.TagName}"); + Logger.Info($"Skipping update prompt for remembered version {releaseTag}"); _appState!.UpdateInfo.Detail = "skipped by user"; return true; } + } + catch (OperationCanceledException) + { + Logger.Info("Update check cancelled"); + if (_appState != null) + { + // Avoid leaving Status="Checking" stale for the manual flow + // or the command-center UI. Surface as Failed with a clear + // "cancelled" detail. + _appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "update check cancelled" + }; + } + return true; + } + catch (Exception ex) + { + Logger.Warn($"Update check failed: {ex.Message}"); + if (_appState != null) + { + _appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = ex.Message + }; + } + return true; + } + finally + { + // Release the gate BEFORE user interaction & download/install. + // Holding it across these long phases would silently time-out + // any concurrent manual click. + _updateCheckGate.Release(); + } - var dialog = new UpdateDialog(release.TagName, changelog); - var result = await dialog.ShowAsync(); + // === Stage 2: user-interactive prompt + download/install === + // Gate is released. Use Interlocked flag so concurrent callers can't + // start a second parallel install while we're prompting/downloading. + if (System.Threading.Interlocked.CompareExchange(ref _updateInstallInProgress, 1, 0) != 0) + { + Logger.Info("Update prompt/install already in progress; skipping"); + if (_appState != null) + { + _appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "an update is already being downloaded or installed" + }; + } + return true; + } + + try + { + var dialog = new UpdateDialog(releaseTag, changelog); + UpdateDialogResult result; + try + { + result = await dialog.ShowAsync(); + } + catch (System.Runtime.InteropServices.COMException ex) + { + // Visual tree torn down mid-await (e.g. window closed). + // Treat as "remind me later" rather than tainting Status with + // "Failed" — the network check itself succeeded. + Logger.Warn($"[Update] Prompt dialog dismissed before completion: 0x{ex.HResult:X8}"); + return true; + } + catch (InvalidOperationException ex) + { + // Another ContentDialog is already open on this XamlRoot. + Logger.Warn($"[Update] Prompt dialog could not be shown: {ex.Message}"); + return true; + } if (result == UpdateDialogResult.Download) { - _appState!.UpdateInfo.Detail = "download requested"; + // Assign a fresh object rather than mutating .Detail in place: + // a concurrent loser of the install-flag CAS may have just + // overwritten _appState.UpdateInfo with a "Failed" object, + // and mutating its Detail would leave Status="Failed" with + // our "download requested" detail — briefly inconsistent. + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, + CheckedAt = DateTime.UtcNow, + Detail = "download requested" + }; if (_settings != null) { _settings.SkippedUpdateTag = string.Empty; _settings.Save(); } var installed = await DownloadAndInstallUpdateAsync(); + if (!installed) + { + // Surface the failure so callers (and the manual-check + // dialog) don't show stale "download requested" state. + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "download or install failed" + }; + } return !installed; // Don't launch if update succeeded } if (result == UpdateDialogResult.Skip && _settings != null) { - _settings.SkippedUpdateTag = release.TagName ?? string.Empty; + _settings.SkippedUpdateTag = releaseTag; + _settings.Save(); + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, + CheckedAt = DateTime.UtcNow, + Detail = "skipped by user" + }; + } + else if (userInitiated && _settings != null + && string.Equals(_settings.SkippedUpdateTag, releaseTag, + StringComparison.OrdinalIgnoreCase)) + { + // User explicitly bypassed the remembered skip for THIS + // release and picked RemindLater — clear the stale tag. + _settings.SkippedUpdateTag = string.Empty; _settings.Save(); - _appState!.UpdateInfo.Detail = "skipped by user"; } - return true; // RemindLater or Skip - continue -#endif + return true; // RemindLater or Skip - continue launch } catch (Exception ex) { - Logger.Warn($"Update check failed: {ex.Message}"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo + Logger.Warn($"Update prompt/install failed: {ex.Message}"); + if (_appState != null) { - Status = "Failed", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - CheckedAt = DateTime.UtcNow, - Detail = ex.Message - }; + _appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = ex.Message + }; + } return true; } + finally + { + System.Threading.Interlocked.Exchange(ref _updateInstallInProgress, 0); + } +#endif } + // Re-entrancy guard: the button/menu/deep-link are all fire-and-forget + // (`_ = CheckForUpdatesUserInitiatedAsync()`), so a double-click would + // otherwise open two ContentDialogs on the same XamlRoot which throws + // COMException. One in-flight manual check at a time is enough. + private int _manualUpdateCheckInFlight; + private async Task CheckForUpdatesUserInitiatedAsync() { - Logger.Info("Manual update check requested"); - var shouldContinue = await CheckForUpdatesAsync(); - UpdateStatusDetailWindow(); - if (!shouldContinue) + if (System.Threading.Interlocked.CompareExchange(ref _manualUpdateCheckInFlight, 1, 0) != 0) { - Exit(); + Logger.Info("Manual update check ignored: another check is already in progress"); + return; + } + + try + { + Logger.Info("Manual update check requested"); + // Pass userInitiated=true so an explicit click bypasses the + // "remind me later" SkippedUpdateTag — the user is asking *now*. + var shouldContinue = await CheckForUpdatesAsync(userInitiated: true); + UpdateStatusDetailWindow(); + + // The "Available" path already prompts via UpdateDialog. For the + // other terminal states a manual click would otherwise produce no + // UI at all, leaving users wondering whether the click registered. + // Surface each explicitly with a small OK dialog. + var info = _appState?.UpdateInfo; + if (info != null) + { + switch (info.Status) + { + case "Current": + await ShowUpdateInfoDialogAsync( + "UpToDate", + LocalizationHelper.GetString("Update_Title_UpToDate"), + LocalizationHelper.Format("Update_Message_UpToDate", info.CurrentVersion)); + break; + case "Failed": + // Format string ends with "\n\n{0}"; an empty Detail + // would leave a dangling blank line. Trim only the + // newline characters we added, never arbitrary + // whitespace from the localized string. + var failedMessage = LocalizationHelper + .Format("Update_Message_Failed", info.Detail ?? "") + .TrimEnd('\r', '\n'); + await ShowUpdateInfoDialogAsync( + "Failed", + LocalizationHelper.GetString("Update_Title_Failed"), + failedMessage); + break; +#if DEBUG + // Status="Skipped" is only produced by the DEBUG short-circuit + // in CheckForUpdatesAsync. User-skipped versions keep + // Status="Available", so this case must not exist in RELEASE + // or it would surface a confusing "disabled in debug builds" + // dialog to end users. + case "Skipped": + await ShowUpdateInfoDialogAsync( + "Skipped", + LocalizationHelper.GetString("Update_Title_Skipped"), + LocalizationHelper.GetString("Update_Message_Skipped_Debug")); + break; +#endif + } + } + + if (!shouldContinue) + { + Exit(); + } + } + finally + { + System.Threading.Interlocked.Exchange(ref _manualUpdateCheckInFlight, 0); + } + } + + private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string message) + { + // Prefer the Hub window when open so the dialog appears modal to what + // the user is actually looking at; fall back to the hidden keep-alive + // window so the dialog still renders if the Hub has been dismissed. + XamlRoot? xamlRoot = null; + if (_hubWindow != null && !_hubWindow.IsClosed) + xamlRoot = (_hubWindow.Content as FrameworkElement)?.XamlRoot; + if (xamlRoot == null) + xamlRoot = (_keepAliveWindow?.Content as FrameworkElement)?.XamlRoot; + if (xamlRoot == null) + { + // Log the stable English key, not the localized title, so log + // grepping works across locales. + Logger.Warn($"[Update] No XAML root available to show dialog: {logKey}"); + return; + } + + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = LocalizationHelper.GetString("Update_OK"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = xamlRoot + }; + try + { + await dialog.ShowAsync(); + } + catch (System.Runtime.InteropServices.COMException ex) + { + // ContentDialog.ShowAsync throws COMException if its XamlRoot's + // visual tree is torn down mid-await (e.g. Hub window closed). + Logger.Warn($"[Update] Dialog dismissed before completion ({logKey}): 0x{ex.HResult:X8}"); + } + catch (InvalidOperationException ex) + { + // WinUI throws InvalidOperationException when another ContentDialog + // is already open on the same thread/XamlRoot. The re-entrancy + // guard only blocks duplicate *update* dialogs; collisions with + // other features' dialogs (onboarding, connection, etc.) must be + // tolerated here so the fire-and-forget call sites don't crash. + Logger.Warn($"[Update] Dialog could not be shown ({logKey}): {ex.Message}"); } } @@ -3510,7 +3592,7 @@ private async Task DownloadAndInstallUpdateAsync() var downloadedAsset = await AppUpdater.DownloadUpdateAsync(); - progressDialog?.Close(); + TryCloseProgressDialog(progressDialog); if (downloadedAsset == null || !System.IO.File.Exists(downloadedAsset.FilePath)) { @@ -3525,11 +3607,30 @@ private async Task DownloadAndInstallUpdateAsync() catch (Exception ex) { Logger.Error($"Update failed: {ex.Message}"); - progressDialog?.Close(); + TryCloseProgressDialog(progressDialog); return false; } } + private static void TryCloseProgressDialog(DownloadProgressDialog? dialog) + { + if (dialog == null) return; + try + { + dialog.Close(); + } + catch (System.Runtime.InteropServices.COMException) + { + // Window already closed — closing a closed WinUI window throws + // COMException 0x80070578. Swallow so a real exception in the + // outer catch isn't masked by this cleanup failure. + } + catch (InvalidOperationException) + { + // Same as above for other "already-disposed" race variants. + } + } + #endregion #region Deep Links diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs index 16807a9d9..bf24bc6d4 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs @@ -26,7 +26,7 @@ public sealed record ChatExplorationPreset public bool IsDefault { get; init; } // Surface - public string BackdropMode { get; init; } = "Mica"; + public string BackdropMode { get; init; } = "Acrylic"; public bool UsesHostBackdrop { get; init; } public string PreviewTheme { get; init; } = "System"; public string Variation { get; init; } = "Calm"; @@ -35,7 +35,8 @@ public sealed record ChatExplorationPreset public double BubbleCornerRadius { get; init; } = 16; public double Gutter { get; init; } = 64; public double MessageGap { get; init; } = 12; - public string PaddingDensity { get; init; } = "Comfortable"; + public string PaddingDensity { get; init; } = "Cozy"; + public string UserBubbleTone { get; init; } = "Secondary"; public bool ShowTimestamps { get; init; } = true; public bool ShowAssistantBubbles { get; init; } = true; public bool ShowToolCalls { get; init; } = true; @@ -43,20 +44,20 @@ public sealed record ChatExplorationPreset public double BubbleSideMargin { get; init; } = 8; // Footer - public bool ShowSenderName { get; init; } = true; - public bool ShowModelName { get; init; } = true; - public bool ShowTokens { get; init; } - public bool ShowContextPercent { get; init; } + public bool ShowSenderName { get; init; } = false; + public bool ShowModelName { get; init; } = false; + public bool ShowTokens { get; init; } = true; + public bool ShowContextPercent { get; init; } = true; // Avatar public bool ShowAvatars { get; init; } = true; - public string AvatarMode { get; init; } = "Both"; + public string AvatarMode { get; init; } = "AgentOnly"; // Composer public string ComposerLayout { get; init; } = "ThreeRow"; public double ComposerCornerRadius { get; init; } = 8; - public double ComposerIconSize { get; init; } = 14; - public double SendButtonSize { get; init; } = 32; + public double ComposerIconSize { get; init; } = 16; + public double SendButtonSize { get; init; } = 40; // Icons public string SendIconGlyph { get; init; } = "\uE724"; @@ -164,6 +165,7 @@ public static void ApplyDefaultIfPresent() Gutter = ChatExplorationState.Gutter, MessageGap = ChatExplorationState.MessageGap, PaddingDensity = ChatExplorationState.PaddingDensity.ToString(), + UserBubbleTone = ChatExplorationState.UserBubbleTone.ToString(), ShowTimestamps = ChatExplorationState.ShowTimestamps, ShowAssistantBubbles = ChatExplorationState.ShowAssistantBubbles, ShowToolCalls = ChatExplorationState.ShowToolCalls, @@ -211,6 +213,7 @@ public static void Apply(ChatExplorationPreset p) ChatExplorationState.Gutter = p.Gutter; ChatExplorationState.MessageGap = p.MessageGap; if (Enum.TryParse(p.PaddingDensity, out var pd)) ChatExplorationState.PaddingDensity = pd; + if (Enum.TryParse(p.UserBubbleTone, out var ut)) ChatExplorationState.UserBubbleTone = ut; ChatExplorationState.ShowTimestamps = p.ShowTimestamps; ChatExplorationState.ShowAssistantBubbles = p.ShowAssistantBubbles; ChatExplorationState.ShowToolCalls = p.ShowToolCalls; diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs index 3abd1fe24..8cc39cb55 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs @@ -32,6 +32,22 @@ public enum ChatPaddingDensity Compact, } +/// +/// Tone used for the user (sent) chat bubble. Accent paints the +/// bubble with the system accent color at full weight (AccentFillColorDefault) — +/// classic iMessage-style bold brand-color bubble. Secondary uses the +/// softer accent variant (AccentFillColorSecondary) — still clearly +/// the accent color, but a step down in saturation so it doesn't compete +/// with the accent-colored avatar / send button. Both tones pair with +/// TextOnAccentFillColorPrimaryBrush, which Fluent guarantees meets +/// WCAG AA contrast in light, dark, and High Contrast themes. +/// +public enum ChatUserBubbleTone +{ + Accent, + Secondary, +} + public enum ChatPreviewTheme { System, @@ -106,6 +122,13 @@ public enum ToolBurstStyle /// Mirrors the AgentRunCard "Running steps / Completed steps" pattern /// from native-chat-v2. TaskList, + /// Smart default — picks per burst state: + /// running bursts render Plain (per-step status visible); + /// terminal multi-step bursts collapse to CompactSummary (1-line summary, + /// click chevron to expand); single-step bursts stay Plain. + /// Matches Scott's feedback: keep live progress visible, fold completed + /// work into a tidy one-liner once the turn finishes. + Auto, } /// @@ -126,23 +149,33 @@ public static class ChatExplorationState private static ChatPreviewState _previewState = ChatPreviewState.Live; private static ChatVariation _variation = ChatVariation.Calm; - private static ChatBackdropMode _backdropMode = ChatBackdropMode.Mica; + // Defaults below were promoted from Kenny's `preset1` (the previous + // per-user IsDefault preset under %APPDATA%\OpenClawTray) so a fresh + // install lands on the same look without needing the JSON preset file. + // Notable diffs vs the original code defaults: + // BackdropMode Mica → Acrylic + // PaddingDensity Comfortable → Cozy + // AvatarMode Both → AgentOnly + // ComposerIconSize 14 → 16 + // SendButtonSize 32 → 40 + private static ChatBackdropMode _backdropMode = ChatBackdropMode.Acrylic; private static ChatPreviewTheme _previewTheme = ChatPreviewTheme.System; private static bool _usesHostBackdrop; private static double _bubbleCornerRadius = 16d; private static double _gutter = 64d; private static double _messageGap = 12d; - private static ChatPaddingDensity _paddingDensity = ChatPaddingDensity.Comfortable; + private static ChatPaddingDensity _paddingDensity = ChatPaddingDensity.Cozy; + private static ChatUserBubbleTone _userBubbleTone = ChatUserBubbleTone.Secondary; private static bool _showTimestamps = true; private static bool _showAvatars = true; - private static ChatAvatarMode _avatarMode = ChatAvatarMode.Both; + private static ChatAvatarMode _avatarMode = ChatAvatarMode.AgentOnly; private static ChatComposerLayout _composerLayout = ChatComposerLayout.ThreeRow; private static double _composerCornerRadius = 8d; - private static double _composerIconSize = 14d; - private static double _sendButtonSize = 32d; + private static double _composerIconSize = 16d; + private static double _sendButtonSize = 40d; private static Brush? _accentBrushOverride; private static Brush? _userBubbleBrushOverride; @@ -155,10 +188,10 @@ public static class ChatExplorationState private static double _bubbleMaxWidth = 560d; private static double _bubbleSideMargin = 8d; - private static bool _showSenderName = true; - private static bool _showModelName = true; - private static bool _showTokens; - private static bool _showContextPercent; + private static bool _showSenderName = false; + private static bool _showModelName = false; + private static bool _showTokens = true; + private static bool _showContextPercent = true; // Default icon glyphs match production OpenClawComposer.cs. private static string _sendIconGlyph = "\uE724"; @@ -242,6 +275,12 @@ public static ChatPaddingDensity PaddingDensity set { if (_paddingDensity != value) { _paddingDensity = value; RaiseChanged(); } } } + public static ChatUserBubbleTone UserBubbleTone + { + get => _userBubbleTone; + set { if (_userBubbleTone != value) { _userBubbleTone = value; RaiseChanged(); } } + } + public static bool ShowTimestamps { get => _showTimestamps; @@ -340,6 +379,20 @@ public static bool ShowToolCalls set { if (_showToolCalls != value) { _showToolCalls = value; RaiseChanged(); } } } + /// + /// Monotonic counter incremented when all tool chip expanded states + /// should be reset. The timeline checks this and clears its local + /// expandedToolChips set when the value changes. + /// + public static int CollapseToolChipsVersion { get; internal set; } + + /// Signal all tool chip expanded states to collapse. + public static void CollapseAllToolChips() + { + CollapseToolChipsVersion++; + RaiseChanged(); + } + public static double BubbleMaxWidth { get => _bubbleMaxWidth; @@ -394,7 +447,7 @@ public static bool ShowContextPercent // ---- Tool burst (H) ---- - private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Plain; + private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Auto; private static bool _showStepNumbers; /// diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs index d9a555b8c..5edfd37b4 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs @@ -25,9 +25,14 @@ public class ChatExplorationsPanel : Component public override Element Render() { var rev = UseState(0, threadSafe: true); + var revRef = UseRef(0); UseEffect((Func)(() => { - EventHandler h = (_, _) => rev.Set(rev.Value + 1); + EventHandler h = (_, _) => + { + revRef.Current++; + rev.Set(revRef.Current); + }; ChatExplorationState.Changed += h; return () => ChatExplorationState.Changed -= h; })); @@ -117,7 +122,10 @@ public override Element Render() v => ChatExplorationState.BubbleSideMargin = v), EnumCombo("Padding density", ChatExplorationState.PaddingDensity, v => ChatExplorationState.PaddingDensity = v, - ChatPaddingDensity.Cozy, ChatPaddingDensity.Comfortable, ChatPaddingDensity.Compact) + ChatPaddingDensity.Cozy, ChatPaddingDensity.Comfortable, ChatPaddingDensity.Compact), + EnumCombo("User bubble tone", ChatExplorationState.UserBubbleTone, + v => ChatExplorationState.UserBubbleTone = v, + ChatUserBubbleTone.Secondary, ChatUserBubbleTone.Accent) ); // ── C.1. Bubble visibility & footer ────────────────────────── @@ -212,6 +220,7 @@ public override Element Render() // ── H. Tool burst (multi-step task framing) ────────────────── // Variants explored here mirror competitor patterns: + // Auto — smart default: Plain while running, CompactSummary when done // Plain — current Cursor-lite (no task framing) // TaskHeader — Cursor's "Tool calls (N steps)" + per-row list // CompactSummary — single collapsed "Task · 3 steps" row, expands @@ -219,6 +228,7 @@ public override Element Render() var toolBurstSection = Section("H. Tool burst style", EnumCombo("Burst style", ChatExplorationState.ToolBurstStyle, v => ChatExplorationState.ToolBurstStyle = v, + ToolBurstStyle.Auto, ToolBurstStyle.Plain, ToolBurstStyle.TaskHeader, ToolBurstStyle.CompactSummary, diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs index fb7622e32..e90adeb6e 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs @@ -19,15 +19,16 @@ public sealed record ChatVariationPreset( public static class ChatVariationPresets { - /// Mica look-alike, large rounded bubbles, generous spacing. + /// Acrylic backdrop, large rounded bubbles, cozy spacing. + /// Matches the shipping default look (see ChatExplorationState code defaults). public static readonly ChatVariationPreset Calm = new( BubbleCornerRadius: 16, Gutter: 64, MessageGap: 12, - PaddingDensity: ChatPaddingDensity.Comfortable, + PaddingDensity: ChatPaddingDensity.Cozy, ComposerCornerRadius: 8, - ComposerIconSize: 14, - SendButtonSize: 32, + ComposerIconSize: 16, + SendButtonSize: 40, ShowAvatars: true, ShowTimestamps: true); diff --git a/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs b/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs index 0d9a2e075..a267925f7 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs @@ -53,7 +53,8 @@ public static MountedFunctionalChat MountFunctionalChat( Action? onSettingsClick = null, Action? onSpeakerMuteChanged = null, bool initialMuted = false, - bool isCompact = false) + bool isCompact = false, + bool suppressAutoDispose = false) { ArgumentNullException.ThrowIfNull(window); ArgumentNullException.ThrowIfNull(target); @@ -61,6 +62,7 @@ public static MountedFunctionalChat MountFunctionalChat( var root = new OpenClawChatRoot(provider, initialThreadId, onReadAloud, onStopSpeaking, onVoiceRequest, onAttachClick, onSettingsClick, onSpeakerMuteChanged, initialMuted, isCompact); var host = new FunctionalHostControl(); + host.SuppressAutoDispose = suppressAutoDispose; host.Mount(root); target.Child = host; return new MountedFunctionalChat(target, host, root); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 83dd83d7e..52aa513d3 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Text.Json; @@ -59,9 +60,23 @@ internal static class LocalizationHelper /// public sealed class OpenClawChatDataProvider : IChatDataProvider { + /// + /// Process-wide cache mapping an attachment's filename to its raw image + /// bytes. Populated by for image + /// attachments so the timeline can render an actual thumbnail in the + /// user bubble (the display-text marker only carries the filename, not + /// the base64 content). Static so any timeline render after a re-mount + /// can still find the image. + /// + public static readonly ConcurrentDictionary ImagePreviewCache = new(); + private readonly IChatGatewayBridge _bridge; private readonly Action? _post; private readonly object _gate = new(); + private readonly object _toolMetaSaveGate = new(); + private readonly string _toolMetaCacheFilePath; + private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes + private long _toolMetaSaveVersion; private readonly Dictionary _timelines = new(); private readonly Dictionary _activeRunIds = new(); // sessionKey → runId private readonly Dictionary _pendingAbortCounts = new(); // threads → count of pending aborts waiting for lifecycle.start @@ -76,6 +91,10 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly Dictionary _sessionIds = new(); // sessionKey → immutable sessionId private readonly HashSet _historyLoaded = new(); // sessionKey private readonly HashSet _historyInFlight = new(); // sessionKey + // Per-session cache of tool metadata from live SSE events. + // Keyed by gateway sessionId (immutable UUID). Persisted to disk + // so that history reconstruction on restart can recover tool names. + private Dictionary> _toolMetaCache; // Track recently-sent local user message texts so we can suppress // SSE echoes while still displaying messages from other clients. private readonly Dictionary> _localSentTexts = new(); @@ -114,11 +133,20 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider /// the source event on (acceptable in unit tests). /// public OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post = null) + : this(bridge, post, DefaultToolMetaCacheFilePath) + { + } + + internal OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post, string toolMetaCacheFilePath) { _bridge = bridge ?? throw new ArgumentNullException(nameof(bridge)); _post = post; + _toolMetaCacheFilePath = !string.IsNullOrWhiteSpace(toolMetaCacheFilePath) + ? toolMetaCacheFilePath + : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolMetaCacheFilePath)); _status = bridge.CurrentStatus; _persistedAbortedIds = LoadAbortedIds(); + _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath); // Seed models from whatever the bridge already knows about (a connect // that completed before the provider was constructed will have its @@ -161,6 +189,21 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation var trimmed = message.Trim(); var nonce = Guid.NewGuid().ToString("N"); + // Cache image attachments by filename so the timeline can render an + // actual thumbnail preview (the display-text marker only carries the + // filename — see ImagePreviewCache notes). + if (hasAttachments) + { + foreach (var a in attachments!) + { + if (a.Type == "image" && !string.IsNullOrEmpty(a.FileName) && !string.IsNullOrEmpty(a.Content)) + { + try { ImagePreviewCache[a.FileName] = Convert.FromBase64String(a.Content); } + catch { /* skip un-decodable bytes */ } + } + } + } + // Build the display text for the user bubble. When attachments are // present, append a structured indicator line so the bubble is never // blank even if the typed message was empty. Uses a unique prefix @@ -374,6 +417,12 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr Logger.Info($"[ChatHistory] Loading thread '{threadId}' — {ordered.Count} messages from gateway"); + // Load cached tool metadata for this session to restore tool names + // that the gateway strips from history responses. + var cachedTools = GetCachedToolMetaForSession(history.SessionId); + if (cachedTools is not null) + Logger.Info($"[ChatHistory] Found {cachedTools.Count} cached tool metadata entries for session"); + bool nextAssistantIsAborted = false; foreach (var msg in ordered) @@ -470,11 +519,13 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr } if (LooksLikeFlattenedToolOutput(text)) { - var kind = ClassifyFlattenedToolOutput(text); - Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}'"); + var cached = TryMatchCachedTool(cachedTools, msg.Ts); + var kind = cached?.ToolName ?? ClassifyFlattenedToolOutput(text); + var label = cached?.Label ?? ExtractFlattenedToolSummary(text); + Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}' cached={cached is not null}"); rebuilt = ApplyAndCaptureMeta( rebuilt, - new ChatToolStartEvent(kind, kind), + new ChatToolStartEvent(label, kind), msgMeta); rebuilt = ApplyAndCaptureMeta( rebuilt, @@ -499,11 +550,13 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr // the heuristic fires, since the role itself confirms // it's tool output. { - var kind = ClassifyFlattenedToolOutput(text); - Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}')"); + var cached = TryMatchCachedTool(cachedTools, msg.Ts); + var kind = cached?.ToolName ?? ClassifyFlattenedToolOutput(text); + var label = cached?.Label ?? ExtractFlattenedToolSummary(text); + Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}' cached={cached is not null})"); rebuilt = ApplyAndCaptureMeta( rebuilt, - new ChatToolStartEvent(kind, kind), + new ChatToolStartEvent(label, kind), msgMeta); rebuilt = ApplyAndCaptureMeta( rebuilt, @@ -747,6 +800,15 @@ public ValueTask DisposeAsync() { if (_disposed) return ValueTask.CompletedTask; _disposed = true; + System.Threading.Timer? timerToDispose; + lock (_gate) + { + timerToDispose = _toolMetaSaveTimer; + _toolMetaSaveTimer = null; + _toolMetaSaveVersion++; + } + timerToDispose?.Dispose(); + SaveToolMetaCache(); _bridge.StatusChanged -= OnStatusChanged; _bridge.SessionsUpdated -= OnSessionsUpdated; _bridge.ChatMessageReceived -= OnChatMessageReceived; @@ -1003,7 +1065,8 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) lock (_gate) { trMeta = BuildLiveMetaLocked(trThread, message.Ts); } var capped = TruncateForChatEntry(message.Text); var kind = ClassifyFlattenedToolOutput(capped); - ApplyEventAndPublish(trThread, new ChatToolStartEvent(kind, kind), trMeta); + var label = ExtractFlattenedToolSummary(capped); + ApplyEventAndPublish(trThread, new ChatToolStartEvent(label, kind), trMeta); ApplyEventAndPublish(trThread, new ChatToolOutputEvent(capped), trMeta); return; } @@ -1106,6 +1169,13 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) ChatEvent? mapped = MapAgentEvent(evt); if (mapped is null) return; + // Cache tool metadata from live SSE events so it survives app restarts. + if (mapped is ChatToolStartEvent toolStart && !string.IsNullOrEmpty(toolStart.ToolName)) + { + var tsMs0 = evt.Ts > 0 ? (long)evt.Ts : 0L; + CacheToolMeta(threadId, tsMs0, toolStart.ToolName, toolStart.Text); + } + // AgentEventInfo.Ts is a double of unix-epoch ms (per OpenClawGatewayClient). var tsMs = evt.Ts > 0 ? (long)evt.Ts : 0L; ChatEntryMetadata? meta; @@ -1821,19 +1891,75 @@ internal static bool LooksLikeFlattenedToolOutput(string text) /// /// Best-guess kind label for a flattened-tool-output assistant /// message. Used to populate the tool chip's monospace kind suffix. + /// Detects tool types from common output patterns as a heuristic + /// fallback when cached metadata is unavailable. /// internal static string ClassifyFlattenedToolOutput(string text) { + if (string.IsNullOrEmpty(text)) return "exec"; + + // Shell/process markers if (text.Contains("Command still running", StringComparison.Ordinal) || text.Contains("Process exited with code", StringComparison.Ordinal)) - return "process"; + return "bash"; + + // File read patterns (numbered lines like "1. ", "42. ") + if (s_numberedLineRegex.IsMatch(text)) + return "view"; + + // Grep / search result patterns ("path/file.ext:123:matched line") + if (s_grepResultRegex.IsMatch(text)) + return "grep"; + + // Directory listing / glob patterns + if (text.Contains("Directory:", StringComparison.Ordinal) || + text.Contains("Mode ", StringComparison.Ordinal)) + return "glob"; + + // Git output + if (text.StartsWith("commit ", StringComparison.Ordinal) || + text.StartsWith("diff --git", StringComparison.Ordinal) || + text.Contains("Author:", StringComparison.Ordinal) && text.Contains("Date:", StringComparison.Ordinal)) + return "git"; + + // Edit/write patterns + if (text.Contains("successfully created", StringComparison.OrdinalIgnoreCase) || + text.Contains("File written", StringComparison.OrdinalIgnoreCase) || + text.Contains("Applied edit", StringComparison.OrdinalIgnoreCase)) + return "edit"; + + // Exec completed marker if (text.Contains("Exec completed (", StringComparison.Ordinal)) return "exec"; - // Anything matching the CLI-help heuristics is also a shell exec - // result — give it the same chip kind as live exec calls. + return "exec"; } + /// Matches numbered output lines typical of file view output (e.g. " 1. content"). + private static readonly System.Text.RegularExpressions.Regex s_numberedLineRegex = + new(@"^\s*\d+\.\s", System.Text.RegularExpressions.RegexOptions.Compiled | System.Text.RegularExpressions.RegexOptions.Multiline); + + /// Matches grep-style results (path:line:content). + private static readonly System.Text.RegularExpressions.Regex s_grepResultRegex = + new(@"^[^\s:]+\.\w+:\d+:", System.Text.RegularExpressions.RegexOptions.Compiled | System.Text.RegularExpressions.RegexOptions.Multiline); + + /// + /// Extract a short one-line summary from flattened tool output text + /// for use as the tool chip label. Truncates to 80 chars. + /// + internal static string ExtractFlattenedToolSummary(string text) + { + if (string.IsNullOrEmpty(text)) return ""; + // Use the first non-empty line as the summary + var firstLine = text.AsSpan().TrimStart(); + var lineEnd = firstLine.IndexOfAny('\r', '\n'); + if (lineEnd > 0) firstLine = firstLine[..lineEnd]; + var summary = firstLine.Length > 80 + ? new string(firstLine[..77]) + "…" + : new string(firstLine); + return summary; + } + // ── State helpers ── /// @@ -2037,14 +2163,21 @@ private ChatDataSnapshot BuildSnapshotLocked() var defaultThreadId = ResolveDefaultThreadIdLocked(); - var connectionLabel = _status switch - { - ConnectionStatus.Connected => "Connected", - ConnectionStatus.Connecting => "Connecting…", - ConnectionStatus.Disconnected => "Disconnected", - ConnectionStatus.Error => "Disconnected — error", - _ => _status.ToString() - }; + // When the gateway is connected and the handshake completed but no + // session key was advertised, distinguish this from a normal "Connected" + // state so the UI can surface a clear compatibility warning. + var connectionLabel = (_status == ConnectionStatus.Connected + && _bridge.HasHandshakeSnapshot + && string.IsNullOrWhiteSpace(composeKey)) + ? "Incompatible gateway" + : _status switch + { + ConnectionStatus.Connected => "Connected", + ConnectionStatus.Connecting => "Connecting…", + ConnectionStatus.Disconnected => "Disconnected", + ConnectionStatus.Error => "Disconnected — error", + _ => _status.ToString() + }; var composeTarget = composeReady ? new ChatComposeTarget(composeKey, true) @@ -2184,6 +2317,202 @@ private void SaveAbortedIds() catch { /* best-effort persistence */ } } + // ── Tool metadata persistence ───────────────────────────────────── + + /// Cached tool call metadata entry persisted to disk. + internal sealed class CachedToolMeta + { + public long Ts { get; set; } + public string ToolName { get; set; } = ""; + public string Label { get; set; } = ""; + } + + private static string DefaultToolMetaCacheFilePath + { + get + { + var root = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 } overrideDir + ? overrideDir + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray"); + return Path.Combine(root, "tool-metadata.json"); + } + } + + /// Max sessions to keep in the tool metadata cache. + internal const int MaxCachedSessions = 20; + + /// Max tool entries per session in the cache. + internal const int MaxToolEntriesPerSession = 500; + + private static Dictionary> LoadToolMetaCache(string cacheFilePath) + { + try + { + if (!File.Exists(cacheFilePath)) + return new(); + var json = File.ReadAllText(cacheFilePath); + var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); + return dict ?? new(); + } + catch + { + return new(); + } + } + + private void SaveToolMetaCache(long? expectedVersion = null) + { + try + { + Dictionary> snapshot; + lock (_gate) + { + if (expectedVersion is long version && (version != _toolMetaSaveVersion || _disposed)) + return; + + snapshot = _toolMetaCache.ToDictionary( + kv => kv.Key, + kv => kv.Value.Select(e => new CachedToolMeta + { + Ts = e.Ts, + ToolName = e.ToolName, + Label = e.Label + }).ToList(), + StringComparer.Ordinal); + } + + // Evict oldest sessions if over the cap + if (snapshot.Count > MaxCachedSessions) + { + var toRemove = snapshot + .OrderBy(kv => kv.Value.Count > 0 ? kv.Value[^1].Ts : 0) + .Take(snapshot.Count - MaxCachedSessions) + .Select(kv => kv.Key) + .ToList(); + foreach (var k in toRemove) snapshot.Remove(k); + } + + var json = System.Text.Json.JsonSerializer.Serialize(snapshot, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + + lock (_toolMetaSaveGate) + { + if (expectedVersion is long version) + { + lock (_gate) + { + if (version != _toolMetaSaveVersion || _disposed) + return; + } + } + + var dir = Path.GetDirectoryName(_toolMetaCacheFilePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // Write to a unique temp file then atomic move to avoid partial JSON on crash. + var tempPath = _toolMetaCacheFilePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(tempPath, json); + File.Move(tempPath, _toolMetaCacheFilePath, overwrite: true); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // Best-effort cleanup; persistence remains best-effort. + } + } + } + } + catch { /* best-effort persistence */ } + } + + /// + /// Cache a tool call's metadata so it can be recovered when the gateway + /// flattens it during history replay on a future app launch. + /// + internal void CacheToolMeta(string threadId, long tsMs, string toolName, string label) + { + System.Threading.Timer? timerToDispose = null; + long saveVersion; + lock (_gate) + { + if (_disposed) + return; + + if (!_sessionIds.TryGetValue(threadId, out var sessionId) || string.IsNullOrEmpty(sessionId)) + return; + + if (!_toolMetaCache.TryGetValue(sessionId, out var list)) + { + list = new List(); + _toolMetaCache[sessionId] = list; + } + + // Deduplicate by timestamp (same tool event shouldn't be cached twice) + if (list.Count > 0 && list[^1].Ts == tsMs && list[^1].ToolName == toolName) + return; + + list.Add(new CachedToolMeta { Ts = tsMs, ToolName = toolName, Label = label }); + + // Cap per-session entries + if (list.Count > MaxToolEntriesPerSession) + list.RemoveRange(0, list.Count - MaxToolEntriesPerSession); + + // Debounce save — reset the timer on each cache addition so we only + // write once after 500ms of quiescence, avoiding concurrent file writes. + saveVersion = ++_toolMetaSaveVersion; + timerToDispose = _toolMetaSaveTimer; + _toolMetaSaveTimer = new System.Threading.Timer(_ => SaveToolMetaCache(saveVersion), null, 500, Timeout.Infinite); + } + timerToDispose?.Dispose(); + } + + /// + /// Look up cached tool metadata for a session's history reconstruction. + /// Returns a queue of entries sorted by timestamp for sequential consumption. + /// + private Queue? GetCachedToolMetaForSession(string? sessionId) + { + if (string.IsNullOrEmpty(sessionId)) return null; + lock (_gate) + { + if (_toolMetaCache.TryGetValue(sessionId!, out var list) && list.Count > 0) + return new Queue(list.OrderBy(e => e.Ts)); + } + return null; + } + + /// + /// Try to match a history tool entry to a cached metadata entry. + /// Both the cache and history are chronologically ordered, so we consume + /// entries sequentially. The cache stores tool-start timestamps while + /// history stores tool-result timestamps (which can be minutes later), + /// so we match by order rather than timestamp proximity. + /// + internal static CachedToolMeta? TryMatchCachedTool(Queue? cache, long historyTsMs) + { + if (cache is null || cache.Count == 0) return null; + + // Both sequences are chronological. Consume the next cached entry + // for each tool result we encounter in history. + // Guard: if the history timestamp is much OLDER than the next cached + // entry, this toolresult predates our cache — skip it. + var candidate = cache.Peek(); + if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000) + return null; // cached entry is >5 min after this history entry — not a match + + return cache.Dequeue(); + } + /// /// After a successful abort, reload chat.history to capture the __openclaw.id /// of the aborted user message and persist it for future sessions. diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index ae297b5d6..0c9e9cb72 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -107,6 +107,7 @@ public override Element Render() // they receive don't change. Bumping this Root's state invalidates // the whole tree so toggles always show in the live preview. var explorationRev = UseState(0, threadSafe: true); + var explorationRevRef = UseRef(0); var pendingAttachment = UseState(null, threadSafe: true); var speakerMuted = UseState(_initialMuted, threadSafe: true); var voiceTranscript = UseState(null, threadSafe: true); @@ -137,10 +138,11 @@ public override Element Render() var dq = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); EventHandler h = (_, _) => { + explorationRevRef.Current++; if (dq is not null) - dq.TryEnqueue(() => explorationRev.Set(explorationRev.Value + 1)); + dq.TryEnqueue(() => explorationRev.Set(explorationRevRef.Current)); else - explorationRev.Set(explorationRev.Value + 1); + explorationRev.Set(explorationRevRef.Current); }; ChatExplorationState.Changed += h; return () => ChatExplorationState.Changed -= h; @@ -273,7 +275,9 @@ Element BuildLoadingElement() var connectedRaw = snapshot.ConnectionStatus; var hostConnected = connectedRaw is not null && connectedRaw.StartsWith("Connected", StringComparison.OrdinalIgnoreCase); - var connState = hostConnected ? "connected" + var connState = (connectedRaw is not null && connectedRaw.StartsWith("Incompatible", StringComparison.OrdinalIgnoreCase)) + ? "incompatible-gateway" + : hostConnected ? "connected" : (connectedRaw is not null && connectedRaw.StartsWith("Connecting", StringComparison.OrdinalIgnoreCase)) ? "connecting" : "disconnected"; @@ -453,6 +457,7 @@ Element BuildLoadingElement() VoiceTranscript: voiceTranscript.Value, VoiceAudioLevel: voiceAudioLevel.Value, RegisterVoiceStarter: starter => TriggerVoiceRecording = starter, + OnAttachmentPasted: att => pendingAttachment.Set(att), IsCompact: _isCompact)) : Empty(); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index cdeb529b0..3a086bf1f 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -200,15 +200,63 @@ static bool ContainsEntryId(IReadOnlyList entries, string id) static double ClampOffset(double offset, double max) => Math.Max(0, Math.Min(offset, max)); + /// + /// Process-wide cache so we decode each cached image only once. Keyed by + /// byte-array reference so cache invalidates automatically when bytes + /// are replaced. BitmapImage instances are UI-thread-affine but read + /// access from other threads through ImageBrush is safe. + /// + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable _bitmapCache = new(); + + /// + /// Decodes into a , + /// caching the result so repeated renders of the same image don't re-run + /// the decoder. Returns null on any decode failure (renderer will + /// fall back to a filename chip). + /// + static Microsoft.UI.Xaml.Media.Imaging.BitmapImage? TryDecodeBitmap(byte[] bytes) + { + if (_bitmapCache.TryGetValue(bytes, out var existing)) + return existing; + try + { + var stream = new global::Windows.Storage.Streams.InMemoryRandomAccessStream(); + using (var writer = new global::Windows.Storage.Streams.DataWriter(stream)) + { + writer.WriteBytes(bytes); + writer.StoreAsync().AsTask().GetAwaiter().GetResult(); + writer.DetachStream(); + } + stream.Seek(0); + var bmp = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(); + bmp.SetSource(stream); + _bitmapCache.Add(bytes, bmp); + return bmp; + } + catch + { + return null; + } + } + public override Element Render() { // Subscribe to ChatExplorationState so toggles live-rerender the // timeline. Same inline pattern as OpenClawComposer (UseState + // UseEffect — extension methods can't access protected hooks). var explorationRev = UseState(0, threadSafe: true); + var explorationRevRef = UseRef(0); UseEffect((Func)(() => { - EventHandler h = (_, _) => explorationRev.Set(explorationRev.Value + 1); + // Use a Ref for the counter to avoid stale-closure: the effect + // runs once, so explorationRev.Value would be stuck at 0. The + // Ref's .Current is always live, ensuring every Changed event + // produces a unique value → always triggers a re-render. + EventHandler h = (_, _) => + { + explorationRevRef.Current++; + explorationRev.Set(explorationRevRef.Current); + }; ChatExplorationState.Changed += h; return () => ChatExplorationState.Changed -= h; })); @@ -250,6 +298,28 @@ public override Element Render() // collapsed" — matches the web's default-collapsed look. var expandedToolChips = UseState>(new HashSet(), threadSafe: true); + // Track the last-seen CollapseToolChipsVersion so we clear expanded + // state when the user toggles tool calls off (collapsed view should + // start fresh when re-shown). + var lastCollapseVersion = UseRef(ChatExplorationState.CollapseToolChipsVersion); + if (lastCollapseVersion.Current != ChatExplorationState.CollapseToolChipsVersion) + { + lastCollapseVersion.Current = ChatExplorationState.CollapseToolChipsVersion; + if (expandedToolChips.Value.Count > 0) + expandedToolChips.Set(new HashSet()); + } + + // When showToolCalls changes, pre-clear the native StackPanel so the + // reconciler (SyncChildren) only does inserts into an empty panel + // instead of expensive per-element RemoveAt calls that cascade + // Unloaded events through deep visual subtrees. + var prevShowToolCallsRef = UseRef(showToolCalls); + if (prevShowToolCallsRef.Current != showToolCalls) + { + prevShowToolCallsRef.Current = showToolCalls; + contentRef.Current?.Children.Clear(); + } + // Hover state — set of entry ids currently under the pointer. Used to // reveal the trash / speak action icons beside user / assistant // bubbles. Re-renders the whole timeline on hover transitions; that's @@ -456,8 +526,17 @@ static Element TimelineInset(Element child, double top = 2, double bottom = 2) = : (Brush)new SolidColorBrush(Microsoft.UI.Colors.Transparent); var assistantBubbleBg = ChatVisualResolver.AssistantBubbleBrush(themeBrush("SubtleFillColorSecondaryBrush")); var assistantBubbleBdr = themeBrush("ControlStrokeColorDefaultBrush"); - var userBubbleBg = ChatVisualResolver.UserBubbleBrush(themeBrush("AccentFillColorDefaultBrush")); - var userBubbleBdr = themeBrush("AccentFillColorDefaultBrush"); + // User bubble brushes vary with the configured tone. Accent → bold + // brand-color bubble with white text (classic iMessage feel). + // Secondary → ``AccentFillColorSecondaryBrush`` — the same accent + // color at a softer fill weight. Both modes pair with + // ``TextOnAccentFillColorPrimaryBrush``, which Fluent guarantees + // meets WCAG AA contrast against any accent-tinted fill in both + // light and dark themes (Microsoft's Fluent design token spec). + var userToneIsAccent = ChatExplorationState.UserBubbleTone == ChatUserBubbleTone.Accent; + var userBubbleBg = ChatVisualResolver.UserBubbleBrush( + themeBrush(userToneIsAccent ? "AccentFillColorDefaultBrush" : "AccentFillColorSecondaryBrush")); + var userBubbleBdr = themeBrush(userToneIsAccent ? "AccentFillColorDefaultBrush" : "AccentFillColorSecondaryBrush"); var userBubbleFg = themeBrush("TextOnAccentFillColorPrimaryBrush"); var avatarPanelBg = themeBrush("SubtleFillColorTertiaryBrush"); var avatarBorder = themeBrush("ControlStrokeColorDefaultBrush"); @@ -472,10 +551,25 @@ static Element TimelineInset(Element child, double top = 2, double bottom = 2) = ? themeBrush("TextFillColorTertiaryBrush") : themeBrush("TextFillColorSecondaryBrush"); var chatTextFg = themeBrush("TextFillColorPrimaryBrush"); - // Tool chips kept in a slightly cooler/dim shade so they read as - // secondary content next to the assistant bubble. - var toolCardBgBrush = themeBrush("SubtleFillColorTertiaryBrush"); + // Tool chips: very subtle background tint + light border so they + // read as a secondary surface distinct from the filled assistant + // bubble without looking like an empty outlined box. + // CardBackgroundFillColorDefaultBrush is the right semantic key — + // the bubble surface below is opaque (Mica/acrylic isn't being + // used directly), so the LayerOnAcrylic family would render + // incorrectly in dark/HC themes. + var toolCardBgBrush = themeBrush("CardBackgroundFillColorDefaultBrush"); var toolCardBorderBrush = themeBrush("ControlStrokeColorDefaultBrush"); + // High-contrast themes need a thicker border to render at all + // (WinUI guidance: 2px minimum). Detect once at render time so the + // tool card border stays visible when HC is on, normal 1px otherwise. + double toolCardBorderThickness = 1; + try + { + if (new global::Windows.UI.ViewManagement.AccessibilitySettings().HighContrast) + toolCardBorderThickness = 2; + } + catch { /* AccessibilitySettings can throw in unpackaged hosts; default to 1px. */ } // Avatar: 36×36 circle (Kenny uses circular avatars). Same constructor // as before but radius defaults to half the size for a perfect circle. @@ -786,17 +880,52 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst var hasMessage = !string.IsNullOrEmpty(messageText); var hasAttachments = attachmentNames.Count > 0; - // Build attachment card(s) — distinct from the text bubble with - // a subtle background, file icon, and filename. Right-aligned - // like user bubbles but visually differentiated. - Element attachmentCards = Empty(); + // Build attachment elements. Images become real thumbnail previews + // by pulling the original bytes from OpenClawChatDataProvider's + // ImagePreviewCache (populated on Send). Non-image attachments + // remain as compact icon+name chips. Both are placed *inside* the + // same bubble as the message text so the user sees a single + // unified message — matching how Slack/iMessage/etc. show + // image-with-caption posts. + var attachmentElements = new List(); if (hasAttachments) { - var cards = new List(); - foreach (var (icon, name, isImage) in attachmentNames) + foreach (var (_, name, isImage) in attachmentNames) { + if (isImage && OpenClawChatDataProvider.ImagePreviewCache.TryGetValue(name, out var bytes)) + { + var bmp = TryDecodeBitmap(bytes); + if (bmp is not null) + { + const double maxW = 280; + const double maxH = 200; + var pw = bmp.PixelWidth > 0 ? bmp.PixelWidth : (int)maxW; + var ph = bmp.PixelHeight > 0 ? bmp.PixelHeight : (int)maxH; + var scale = Math.Min(Math.Min(maxW / pw, maxH / ph), 1.0); + var w = pw * scale; + var h = ph * scale; + + attachmentElements.Add( + Border(Empty()) + .CornerRadius(8) + .Set(b => + { + b.Width = w; + b.Height = h; + b.Background = new ImageBrush + { + ImageSource = bmp, + Stretch = Stretch.UniformToFill, + }; + b.HorizontalAlignment = HorizontalAlignment.Right; + })); + continue; + } + } + + // Fallback chip (file attachment or missing image bytes). var fileGlyph = isImage ? "\uEB9F" : "\uE8A5"; // Photo / Page - var card = Border( + attachmentElements.Add(Border( Grid([GridSize.Auto, GridSize.Star()], [GridSize.Auto], Border( TextBlock(fileGlyph) @@ -827,24 +956,23 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst ) ).Set(b => { - b.CornerRadius = bubbleRadius; - b.Padding = new Thickness(10, 8, 14, 8); - b.VerticalAlignment = VerticalAlignment.Center; + b.CornerRadius = new CornerRadius(6); + b.Padding = new Thickness(8, 6, 12, 6); b.BorderThickness = new Thickness(1); b.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF)); - }).Background(ChatVisualResolver.UserBubbleBrush( - themeBrush("AccentFillColorSecondaryBrush"))); - - cards.Add(card.HAlign(HorizontalAlignment.Right)); + b.Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)); + })); } - attachmentCards = VStack(4, cards.ToArray()); } - // Standard text bubble (only when there's actual text). - Element bubble = Empty(); + // Build the unified bubble: attachments stacked at the top, text + // below them. A single Border with the user-bubble background + + // bubbleRadius wraps both so they read as one message. + var bubbleChildren = new List(); + foreach (var ae in attachmentElements) bubbleChildren.Add(ae); if (hasMessage) { - bubble = Border( + bubbleChildren.Add( TextBlock(messageText) .Set(t => { @@ -852,24 +980,31 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst t.FontSize = 14; t.Foreground = userBubbleFg; t.IsTextSelectionEnabled = true; - }) + })); + } + + Element content; + if (bubbleChildren.Count > 0) + { + content = Border( + VStack(8, bubbleChildren.ToArray()) ).Background(userBubbleBg) .Set(b => { b.CornerRadius = bubbleRadius; - b.Padding = bubblePadding; + // When the bubble contains only an image, tighten the + // padding so the thumbnail nearly fills the bubble. + b.Padding = (hasAttachments && !hasMessage) + ? new Thickness(6, 6, 6, 6) + : bubblePadding; b.VerticalAlignment = VerticalAlignment.Center; - }); + }) + .HAlign(HorizontalAlignment.Right); } - - // Combine: text bubble above attachment card(s). - Element content; - if (hasMessage && hasAttachments) - content = VStack(4, bubble.HAlign(HorizontalAlignment.Right), attachmentCards); - else if (hasAttachments) - content = attachmentCards; else - content = bubble.HAlign(HorizontalAlignment.Right); + { + content = Empty(); + } // Avatar shown only on the LAST entry of a same-sender burst, // and only when ChatExplorationState.AvatarMode allows. When @@ -885,7 +1020,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst [GridSize.Star(), GridSize.Auto], [GridSize.Auto], content.HAlign(HorizontalAlignment.Right).Grid(row: 0, column: 0), - rightSlot.Grid(row: 0, column: 1).Margin(bubbleSideMargin, 0, 0, 0) + rightSlot.Grid(row: 0, column: 1).Margin(showUserAvatar ? bubbleSideMargin : 0, 0, 0, 0) ).HAlign(HorizontalAlignment.Stretch); Element footer = Empty(); @@ -894,6 +1029,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst var entryMeta = MetaFor(entry.Id); var timeStr = FormatTime(entryMeta?.Timestamp); var rightInset = showUserAvatar ? (36 + bubbleSideMargin) : 0; + rightInset += (int)bubblePadding.Right; footer = BuildUserFooter(userSender, timeStr, chatStampFg, entry.Id, entry.Text ?? "") .Margin(0, 2, rightInset, 0); } @@ -905,11 +1041,17 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst VStack(2, bubbleRow, footer) .HAlign(HorizontalAlignment.Stretch) ).Background(new SolidColorBrush(Colors.Transparent)) - .Margin(gutter, topMargin, 8, bottomMargin), + .Margin(gutter, topMargin, 16, bottomMargin), entry.Id); } - Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar) + // Per-turn shared reference between the assistant bubble and any + // tool cards rendered below it. The tool card binds its Width to + // bubble.ActualWidth - toolIndent so the two cards' right edges + // (and left indent) stay exactly parallel as the bubble grows + // with content. Single-element Border[] used as a mutable slot + // since these are local functions (no nested class allowed). + Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null, Element? nestedTool = null) { if (string.IsNullOrEmpty(entry.Text)) return Empty(); @@ -919,30 +1061,49 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends return Empty(); // Avatar shown only on the FIRST entry of a contiguous agent-side - // run (Assistant + ToolCall task cards count as one agent block). - // Mid-run entries get a spacer so the bubble stays aligned with - // the first bubble that does carry the avatar. - Element leftSlot = !showAssistAvatar + // run. Continuation entries get no spacer — they align flush with + // the tool burst cards above (which also sit at the left inset), + // so the agent column reads as a single vertical edge. + Element leftSlot = !showAssistAvatar || !showAvatar ? Empty() - : (showAvatar - ? AssistantAvatar().VAlign(VerticalAlignment.Top) - : Border(Empty()).Size(36, 36)); + : AssistantAvatar().VAlign(VerticalAlignment.Top); // Assistant bubble — subtle gray with primary text. Radius/Padding // come from ChatExplorationState (BubbleCornerRadius + PaddingDensity). + // HAlign=Left keeps the bubble anchored next to the avatar/timestamp + // column. MaxWidth=720 caps the growth so long messages stop where + // the tool burst card's max right edge lands. + // When `nestedTool` is supplied, the tool burst (single chip OR + // collapsed multi-step summary) is rendered INSIDE the bubble's + // content area — directly below the assistant text with a small + // top gap — so it visually reads as a child of the bubble. + Element bubbleContent = SafeMarkdownText(entry.Text); + if (nestedTool != null) + { + // Top gap (markdown bottom → tool card top) needs to be a + // little larger than the bubble's bottom padding so the + // optical spacing matches the gap from the tool card to the + // bubble's bottom edge — Markdown text has very tight + // line-height with no trailing descender, so a literal-equal + // gap reads as visibly tighter on top. + var nestedTopGap = (int)Math.Round(bubblePadding.Bottom + 4); + bubbleContent = VStack(nestedTopGap, bubbleContent, nestedTool); + } var card = Border( - SafeMarkdownText(entry.Text) + bubbleContent ).Background(assistantBubbleBg) .Set(b => { b.CornerRadius = bubbleRadius; b.Padding = bubblePadding; + b.MaxWidth = 720; + if (bubbleSlot != null) bubbleSlot[0] = b; }); var bubbleRow = Grid( [GridSize.Auto, GridSize.Star()], [GridSize.Auto], - leftSlot.Grid(row: 0, column: 0).Margin(0, 0, bubbleSideMargin, 0), + leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0), card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1) ).HAlign(HorizontalAlignment.Stretch); @@ -956,19 +1117,25 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends entryMeta?.InputTokens, entryMeta?.OutputTokens, entryMeta?.ResponseTokens, entryMeta?.ContextPercent, chatStampFg, entry.Id, entry.Text ?? ""); - var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0; + var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0; + leftInset += (int)bubblePadding.Left; footer = footer.Margin(leftInset, 2, 0, 0); } var topMargin = startsBurst ? 4.0 : 1.0; var bottomMargin = endsBurst ? 4.0 : 1.0; + // AutomationName: when the bubble nests a tool burst inside it, + // UIA would treat the named container as a leaf and hide the + // nested tool card from screen readers. Drop the bubble-level + // name in the nested case — the markdown text inside is read + // out by UIA on its own, and Narrator can then traverse into + // the nested tool card as a sibling child. + var stack = VStack(2, bubbleRow, footer).HAlign(HorizontalAlignment.Stretch); + if (nestedTool == null) + stack = stack.AutomationName(entry.Text ?? ""); return WithHoverHandlers( - Border( - VStack(2, bubbleRow, footer) - .HAlign(HorizontalAlignment.Stretch) - .AutomationName(entry.Text ?? "") - ).Background(new SolidColorBrush(Colors.Transparent)) - .Margin(8, topMargin, gutter, bottomMargin), + Border(stack).Background(new SolidColorBrush(Colors.Transparent)) + .Margin(16, topMargin, gutter, bottomMargin), entry.Id); } @@ -978,7 +1145,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends // into `▸ ⚡ · [Done]`; click expands the row // to reveal the original args + raw output (the previous chip body). // A single trailing `Tool · private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability availability) @@ -205,15 +205,14 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability avai r.Contains("Windows build", StringComparison.OrdinalIgnoreCase) || r.Contains("Windows UBR", StringComparison.OrdinalIgnoreCase)); - var isSetupIssue = !availability.IsWxcExecResolvable - || availability.RunCommandScriptPath is null; + var isSetupIssue = !availability.IsWxcExecResolvable; if (isWindowsIssue) { UnavailableActionBar.Title = "Your Windows version doesn't support sandboxing yet"; UnavailableActionMessage.Text = - $"{reasonText}\n\nMXC sandboxing requires a recent Windows build with the AppContainer primitives shipped. " + - "Install the latest Windows updates (or join the Windows Insider Program for the newest builds)."; + $"{reasonText}\n\nCommands run uncontained on this machine — sandboxing requires a recent Windows build with the AppContainer primitives shipped. " + + "Install the latest Windows updates (or join the Windows Insider Program for the newest builds) to enable containment."; UnavailablePrimaryButton.Content = "Open Windows Update"; UnavailablePrimaryButton.Tag = "windowsupdate"; UnavailablePrimaryButton.Visibility = Visibility.Visible; @@ -222,16 +221,16 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability avai { UnavailableActionBar.Title = "Sandboxing components are missing"; UnavailableActionMessage.Text = - $"{reasonText}\n\nThe MXC bridge script or the wxc-exec binary couldn't be located. " + - "If this is a developer build, run `npm ci` at the repository root. " + - "Otherwise reinstall the companion app."; + $"{reasonText}\n\nThe wxc-exec binary couldn't be located, so commands run uncontained. " + + "If this is a developer build, build the tray app so wxc-exec.exe is copied into the output folder. " + + "Otherwise reinstall the companion app to restore sandboxing."; UnavailablePrimaryButton.Content = "Show install instructions"; UnavailablePrimaryButton.Tag = "install"; UnavailablePrimaryButton.Visibility = Visibility.Visible; } else { - UnavailableActionBar.Title = "Sandbox unavailable"; + UnavailableActionBar.Title = "Sandbox unavailable — commands run uncontained"; UnavailableActionMessage.Text = reasonText; UnavailablePrimaryButton.Visibility = Visibility.Collapsed; } diff --git a/src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs b/src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs new file mode 100644 index 000000000..4c3b3e7eb --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs @@ -0,0 +1,47 @@ +namespace OpenClawTray.Services; + +/// +/// Writes and clears a run marker file so the next launch can detect an unclean exit. +/// +internal sealed class AppRunMarker +{ + private readonly string _path; + + public AppRunMarker(string path) => _path = path; + + public void Check() + { + try + { + if (File.Exists(_path)) + { + var startedAt = File.ReadAllText(_path); + Logger.Error($"Previous session did not exit cleanly (started {startedAt})"); + File.Delete(_path); + } + } + catch { } + } + + public void MarkStarted() + { + try + { + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(_path, DateTime.Now.ToString("O")); + } + catch { } + } + + public void MarkEnded() + { + try + { + if (File.Exists(_path)) + File.Delete(_path); + } + catch { } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs index 850722702..72b4f2c6b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -138,6 +138,7 @@ public sealed class LocalGatewaySetupState { public int SchemaVersion { get; set; } = 1; public string RunId { get; set; } = Guid.NewGuid().ToString("N"); + public string InstallId { get; set; } = Guid.NewGuid().ToString("N"); public LocalGatewaySetupPhase Phase { get; set; } = LocalGatewaySetupPhase.NotStarted; public LocalGatewaySetupStatus Status { get; set; } = LocalGatewaySetupStatus.Pending; public string DistroName { get; set; } = "OpenClawGateway"; @@ -266,12 +267,18 @@ public interface IWslCommandRunner public sealed class WslExeCommandRunner : IWslCommandRunner { private readonly IOpenClawLogger _logger; + private readonly ILocalGatewaySetupDiagnosticsSink _diagnostics; private readonly TimeSpan _defaultTimeout; private readonly TimeSpan _streamDrainTimeout; - public WslExeCommandRunner(IOpenClawLogger? logger = null, TimeSpan? defaultTimeout = null, TimeSpan? streamDrainTimeout = null) + public WslExeCommandRunner( + IOpenClawLogger? logger = null, + TimeSpan? defaultTimeout = null, + TimeSpan? streamDrainTimeout = null, + ILocalGatewaySetupDiagnosticsSink? diagnostics = null) { _logger = logger ?? NullLogger.Instance; + _diagnostics = diagnostics ?? NullLocalGatewaySetupDiagnosticsSink.Instance; _defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(30); _streamDrainTimeout = streamDrainTimeout ?? TimeSpan.FromSeconds(5); } @@ -377,7 +384,9 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL ApplyEnvironment(psi, environment); - _logger.Info($"[WSL] {fileName} {string.Join(" ", arguments.Select(RedactArgument))}"); + _logger.Info($"[WSL] {fileName} {string.Join(" ", RedactArguments(arguments))}"); + var commandId = _diagnostics.CommandStarted(fileName, arguments, _defaultTimeout); + var sw = Stopwatch.StartNew(); using var process = new Process { StartInfo = psi }; try @@ -386,7 +395,10 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL } catch (Exception ex) { - return new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}"); + var result = new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}"); + sw.Stop(); + _diagnostics.CommandCompleted(commandId, fileName, arguments, sw.Elapsed, result, timedOut: false); + return result; } var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); @@ -424,6 +436,14 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL { _logger.Warn($"[WSL] Failed to kill cancelled process: {ex.Message}"); } + sw.Stop(); + _diagnostics.CommandCompleted( + commandId, + fileName, + arguments, + sw.Elapsed, + new WslCommandResult(-1, string.Empty, "wsl.exe cancelled"), + timedOut: false); throw; } @@ -437,10 +457,13 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL var stdout = await DrainAsync(stdoutTask, _streamDrainTimeout, _logger, isStderr: false); var stderr = await DrainAsync(stderrTask, _streamDrainTimeout, _logger, isStderr: true); - if (timedOut) - return new WslCommandResult(-1, stdout, "wsl.exe timed out"); + var finalResult = timedOut + ? new WslCommandResult(-1, stdout, "wsl.exe timed out") + : new WslCommandResult(process.ExitCode, stdout, stderr); - return new WslCommandResult(process.ExitCode, stdout, stderr); + sw.Stop(); + _diagnostics.CommandCompleted(commandId, fileName, arguments, sw.Elapsed, finalResult, timedOut); + return finalResult; } internal static async Task DrainAsync(Task readTask, TimeSpan drainTimeout, IOpenClawLogger logger, bool isStderr) @@ -509,12 +532,8 @@ private static void AppendWslEnvPassthrough(IDictionary environm environment["WSLENV"] = string.IsNullOrWhiteSpace(existing) ? entry : existing + ":" + entry; } - private static string RedactArgument(string argument) => - SecretRedactor.Redact(argument.Contains("token", StringComparison.OrdinalIgnoreCase) - || argument.Contains("private", StringComparison.OrdinalIgnoreCase) - || argument.Contains("setupCode", StringComparison.OrdinalIgnoreCase) - ? "" - : argument); + private static IEnumerable RedactArguments(IReadOnlyList arguments) => + SetupDiagnosticsRedactor.RedactArguments(arguments); } public sealed record LocalGatewayPreflightResult( @@ -3367,6 +3386,7 @@ public sealed class LocalGatewaySetupEngine private readonly IOperatorPairingService _operatorPairing; private readonly IWindowsTrayNodeProvisioner _windowsTrayNode; private readonly IOpenClawLogger _logger; + private readonly ILocalGatewaySetupDiagnosticsSink _diagnostics; public event Action? StateChanged; @@ -3414,7 +3434,8 @@ public LocalGatewaySetupEngine( IGatewayConfigurationPreparer? gatewayConfigurationPreparer = null, IGatewayServiceManager? gatewayServiceManager = null, ILocalGatewayEndpointResolver? endpointResolver = null, - ISharedGatewayTokenProvisioner? sharedGatewayTokenProvisioner = null) + ISharedGatewayTokenProvisioner? sharedGatewayTokenProvisioner = null, + ILocalGatewaySetupDiagnosticsSink? diagnosticsSink = null) { _options = options; _stateStore = stateStore; @@ -3432,13 +3453,19 @@ public LocalGatewaySetupEngine( _operatorPairing = operatorPairing; _windowsTrayNode = windowsTrayNode; _logger = logger ?? NullLogger.Instance; + _diagnostics = diagnosticsSink ?? NullLocalGatewaySetupDiagnosticsSink.Instance; } public async Task RunLocalOnlyAsync(CancellationToken cancellationToken = default) { + var runStopwatch = Stopwatch.StartNew(); var state = await _stateStore.LoadAsync(cancellationToken) ?? LocalGatewaySetupState.Create(_options); + state.RunId = Guid.NewGuid().ToString("N"); + if (string.IsNullOrWhiteSpace(state.InstallId)) + state.InstallId = Guid.NewGuid().ToString("N"); state.DistroName = _options.DistroName; state.GatewayUrl = LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(_options); + _diagnostics.RunStarted(state, _options); var distroExists = await HasDistroAsync(cancellationToken); var allowExistingDistroForRun = ShouldAllowExistingDistroForRun(state, distroExists, _options.AllowExistingDistro); var preflightOptions = _options with { AllowExistingDistro = allowExistingDistroForRun }; @@ -3509,6 +3536,8 @@ await RunPhaseAsync(state, LocalGatewaySetupPhase.ConfigureWslInstance, "Configu await RunPhaseAsync(state, LocalGatewaySetupPhase.InstallOpenClawCli, "Installing OpenClaw inside WSL", async () => { var result = await _openClawLinuxInstaller.InstallAsync(_options, cancellationToken); + foreach (var installerEvent in result.Events ?? Array.Empty()) + _diagnostics.InstallerEvent(LocalGatewaySetupPhase.InstallOpenClawCli, installerEvent); if (!result.Success) { if (!string.IsNullOrWhiteSpace(result.Detail)) @@ -3609,6 +3638,9 @@ await RunProvisioningPhaseAsync(state, LocalGatewaySetupPhase.PairWindowsTrayNod await SaveAndPublishAsync(state, cancellationToken); } + runStopwatch.Stop(); + _diagnostics.RunCompleted(state, runStopwatch.Elapsed); + await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken); return state; } @@ -3672,7 +3704,9 @@ private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetup if (state.Status is not LocalGatewaySetupStatus.Pending and not LocalGatewaySetupStatus.Running) return; + var phaseStopwatch = Stopwatch.StartNew(); state.StartPhase(phase, message); + _diagnostics.PhaseStarted(state, phase, message); await SaveAndPublishAsync(state, cancellationToken); bool completed; try @@ -3686,6 +3720,9 @@ private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetup // Persist cancelled state so restarts don't resume from stale Running phase try { await _stateStore.SaveAsync(state, CancellationToken.None); } catch { } StateChanged?.Invoke(state); + phaseStopwatch.Stop(); + _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed); + await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), CancellationToken.None); throw; } catch (Exception ex) @@ -3693,18 +3730,27 @@ private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetup _logger.Error($"Local gateway setup phase {phase} failed.", ex); var retryable = ex is not (UnauthorizedAccessException or NotSupportedException or InvalidOperationException or ArgumentException); state.Block($"{phase.ToString().ToLowerInvariant()}_failed", ex.Message, retryable: retryable, detail: SecretRedactor.Redact(ex.ToString())); + phaseStopwatch.Stop(); + _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed); await SaveAndPublishAsync(state, cancellationToken); + await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken); return; } if (completed && state.Status == LocalGatewaySetupStatus.Running) { state.CompletePhase(phase, message); + phaseStopwatch.Stop(); + _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed); await SaveAndPublishAsync(state, cancellationToken); } else if (!completed) { + phaseStopwatch.Stop(); + _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed); await SaveAndPublishAsync(state, cancellationToken); + if (state.Status is LocalGatewaySetupStatus.FailedRetryable or LocalGatewaySetupStatus.FailedTerminal or LocalGatewaySetupStatus.Blocked) + await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken); } } @@ -3764,71 +3810,106 @@ public sealed class LocalGatewayLifecycleManager : ILocalGatewayLifecycleManager private readonly ILocalGatewayHealthProbe _healthProbe; private readonly ILocalGatewaySetupSettings? _settings; private readonly IOpenClawLogger? _logger; + private readonly ILocalGatewaySetupDiagnosticsSink _diagnostics; - public LocalGatewayLifecycleManager(LocalGatewaySetupOptions options, IWslCommandRunner wsl, ILocalGatewayHealthProbe healthProbe, ILocalGatewaySetupSettings? settings = null, IOpenClawLogger? logger = null) + public LocalGatewayLifecycleManager( + LocalGatewaySetupOptions options, + IWslCommandRunner wsl, + ILocalGatewayHealthProbe healthProbe, + ILocalGatewaySetupSettings? settings = null, + IOpenClawLogger? logger = null, + ILocalGatewaySetupDiagnosticsSink? diagnosticsSink = null) { _options = options; _wsl = wsl; _healthProbe = healthProbe; _settings = settings; _logger = logger; + _diagnostics = diagnosticsSink ?? NullLocalGatewaySetupDiagnosticsSink.Instance; } public async Task RepairAsync(CancellationToken cancellationToken = default) { + var lifecycleStopwatch = Stopwatch.StartNew(); + _diagnostics.LifecycleStarted("repair"); var steps = new List(); var distros = await _wsl.ListDistrosAsync(cancellationToken); if (!distros.Any(d => d.Name.Equals(_options.DistroName, StringComparison.OrdinalIgnoreCase) && d.Version == 2)) - return Fail("distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found.", steps); + { + _diagnostics.LifecycleStep("repair", "distro_present", success: false, "distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found."); + return await CompleteLifecycleAsync("repair", Fail("distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found.", steps), lifecycleStopwatch, cancellationToken); + } // Tear down any stale keepalive before terminating; we'll spawn a fresh one // after the gateway becomes healthy. Without this, the old keepalive lingers // pointing at a now-restarted VM but is no longer tracked by our marker. WslDistroKeepAlive.Stop(_options.DistroName, _logger); steps.Add("keepalive_stopped"); + _diagnostics.LifecycleStep("repair", "keepalive_stopped", success: true); await _wsl.TerminateDistroAsync(_options.DistroName, cancellationToken); steps.Add("distro_terminated"); + _diagnostics.LifecycleStep("repair", "distro_terminated", success: true); var daemonReload = await RunInDistroAsRootAsync(["systemctl", "daemon-reload"], cancellationToken); steps.Add("daemon_reloaded"); if (!daemonReload.Success) - return Fail("daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units.", steps); + { + _diagnostics.LifecycleStep("repair", "daemon_reloaded", success: false, "daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units."); + return await CompleteLifecycleAsync("repair", Fail("daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units.", steps), lifecycleStopwatch, cancellationToken); + } + _diagnostics.LifecycleStep("repair", "daemon_reloaded", success: true); var gateway = await RestartGatewayServiceAsync(steps, cancellationToken); if (!gateway.Success) - return gateway; + return await CompleteLifecycleAsync("repair", gateway, lifecycleStopwatch, cancellationToken); var health = await _healthProbe.WaitForHealthyAsync(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(_options), cancellationToken); steps.Add("gateway_health_checked"); if (!health.Success) - return Fail("gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair."), steps); + { + _diagnostics.LifecycleStep("repair", "gateway_health_checked", success: false, "gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair.")); + return await CompleteLifecycleAsync("repair", Fail("gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair."), steps), lifecycleStopwatch, cancellationToken); + } + _diagnostics.LifecycleStep("repair", "gateway_health_checked", success: true); // Re-arm the keepalive so the VM stays up after repair completes, even if the // tray that triggered repair exits before the next OnLaunched hook runs. WslDistroKeepAlive.EnsureStarted(_options.DistroName, _logger); steps.Add("keepalive_started"); + _diagnostics.LifecycleStep("repair", "keepalive_started", success: true); - return new LocalGatewayLifecycleResult(true, Steps: steps); + return await CompleteLifecycleAsync("repair", new LocalGatewayLifecycleResult(true, Steps: steps), lifecycleStopwatch, cancellationToken); } public async Task RemoveAsync(LocalGatewayRemoveRequest request, CancellationToken cancellationToken = default) { + var lifecycleStopwatch = Stopwatch.StartNew(); + _diagnostics.LifecycleStarted("remove"); var steps = new List(); if (!request.ConfirmRemove) - return Fail("confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation.", steps); + { + _diagnostics.LifecycleStep("remove", "confirmation_required", success: false, "confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation."); + return await CompleteLifecycleAsync("remove", Fail("confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation.", steps), lifecycleStopwatch, cancellationToken); + } // Stop the keepalive before terminating so the marker file does not survive // distro removal and confuse a future install with the same name. WslDistroKeepAlive.Stop(_options.DistroName, _logger); steps.Add("keepalive_stopped"); + _diagnostics.LifecycleStep("remove", "keepalive_stopped", success: true); await _wsl.TerminateDistroAsync(_options.DistroName, cancellationToken); steps.Add("distro_terminated"); + _diagnostics.LifecycleStep("remove", "distro_terminated", success: true); var unregister = await _wsl.UnregisterDistroAsync(_options.DistroName, cancellationToken); steps.Add("distro_unregistered"); if (!unregister.Success) - return Fail("distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'.", steps); + { + _diagnostics.LifecycleStep("remove", "distro_unregistered", success: false, "distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'."); + return await CompleteLifecycleAsync("remove", Fail("distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'.", steps), lifecycleStopwatch, cancellationToken); + } + _diagnostics.LifecycleStep("remove", "distro_unregistered", success: true); if (request.ClearLocalCredentials && _settings is not null) { @@ -3838,12 +3919,13 @@ public async Task RemoveAsync(LocalGatewayRemoveReq _settings.UseSshTunnel = false; _settings.Save(); steps.Add("local_credentials_cleared"); + _diagnostics.LifecycleStep("remove", "local_credentials_cleared", success: true); } if (request.PreserveRelayRegistration) steps.Add("relay_registration_preserved"); - return new LocalGatewayLifecycleResult(true, Steps: steps); + return await CompleteLifecycleAsync("remove", new LocalGatewayLifecycleResult(true, Steps: steps), lifecycleStopwatch, cancellationToken); } private async Task RestartGatewayServiceAsync(List steps, CancellationToken cancellationToken) @@ -3852,17 +3934,29 @@ private async Task RestartGatewayServiceAsync(List< var enable = await RunInDistroAsRootAsync(["systemctl", "enable", "--now", $"{serviceName}.service"], cancellationToken); steps.Add($"{serviceName}_enabled"); if (!enable.Success) + { + _diagnostics.LifecycleStep("repair", $"{serviceName}_enabled", success: false, "service_enable_failed", $"Failed to enable {serviceName}.service."); return Fail("service_enable_failed", $"Failed to enable {serviceName}.service.", steps); + } + _diagnostics.LifecycleStep("repair", $"{serviceName}_enabled", success: true); var restart = await RunInDistroAsRootAsync(["systemctl", "restart", $"{serviceName}.service"], cancellationToken); steps.Add($"{serviceName}_restarted"); if (!restart.Success) + { + _diagnostics.LifecycleStep("repair", $"{serviceName}_restarted", success: false, "service_restart_failed", $"Failed to restart {serviceName}.service."); return Fail("service_restart_failed", $"Failed to restart {serviceName}.service.", steps); + } + _diagnostics.LifecycleStep("repair", $"{serviceName}_restarted", success: true); var active = await RunInDistroAsRootAsync(["systemctl", "is-active", "--quiet", $"{serviceName}.service"], cancellationToken); steps.Add($"{serviceName}_active_checked"); if (!active.Success) + { + _diagnostics.LifecycleStep("repair", $"{serviceName}_active_checked", success: false, "service_inactive", $"{serviceName}.service is not active after repair."); return Fail("service_inactive", $"{serviceName}.service is not active after repair.", steps); + } + _diagnostics.LifecycleStep("repair", $"{serviceName}_active_checked", success: true); return new LocalGatewayLifecycleResult(true, Steps: steps); } @@ -3874,6 +3968,18 @@ private Task RunInDistroAsRootAsync(IReadOnlyList comm return _wsl.RunAsync(args, cancellationToken); } + private async Task CompleteLifecycleAsync( + string operation, + LocalGatewayLifecycleResult result, + Stopwatch stopwatch, + CancellationToken cancellationToken) + { + stopwatch.Stop(); + _diagnostics.LifecycleCompleted(operation, result, stopwatch.Elapsed); + await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken); + return result; + } + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; private static LocalGatewayLifecycleResult Fail(string errorCode, string errorMessage, IReadOnlyList steps) => new(false, errorCode, errorMessage, steps); } @@ -3950,7 +4056,8 @@ public static LocalGatewaySetupEngine CreateLocalOnly( catch { /* best-effort — engine will overwrite on first save */ } } - var wsl = new WslExeCommandRunner(logger, TimeSpan.FromMinutes(30)); + var diagnostics = new LocalGatewaySetupDiagnosticsService(); + var wsl = new WslExeCommandRunner(logger, TimeSpan.FromMinutes(30), diagnostics: diagnostics); var settingsAdapter = new SettingsManagerLocalGatewaySetupSettings(settings, gatewayRegistry); var bootstrapTokenProvider = new WslGatewayCliBootstrapTokenProvider(wsl, options.OpenClawInstallPrefix + "/bin/openclaw"); var sharedGatewayTokenProvider = new WslGatewayCliSharedGatewayTokenProvider(wsl); @@ -3968,7 +4075,8 @@ public static LocalGatewaySetupEngine CreateLocalOnly( new SettingsWindowsTrayNodeProvisioner(settingsAdapter, windowsNodeConnector, pendingDeviceApprover), logger, gatewayConfigurationPreparer: gatewayConfigurationPreparer, - sharedGatewayTokenProvisioner: new SettingsSharedGatewayTokenProvisioner(settingsAdapter, sharedGatewayTokenProvider, gatewayConfigurationPreparer)); + sharedGatewayTokenProvisioner: new SettingsSharedGatewayTokenProvisioner(settingsAdapter, sharedGatewayTokenProvider, gatewayConfigurationPreparer), + diagnosticsSink: diagnostics); } private static string ResolveDistroName(LocalGatewaySetupRuntimeConfiguration runtime, string? explicitDistroName) diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs new file mode 100644 index 000000000..c05777fe1 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs @@ -0,0 +1,751 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services; + +namespace OpenClawTray.Services.LocalGatewaySetup; + +/// +/// Schema v1 for easy-button setup diagnostics. +/// +/// Required top-level JSONL fields: +/// schema_version, timestamp_utc, run_id, install_id, level, event. +/// +/// Optional top-level JSONL fields: +/// phase, visible_stage, status, message, failure_code, retryable, +/// duration_ms, details. +/// +public interface ILocalGatewaySetupDiagnosticsSink +{ + string? RunTracePath { get; } + string? LatestTracePath { get; } + string? LatestSummaryPath { get; } + + void RunStarted(LocalGatewaySetupState state, LocalGatewaySetupOptions options); + void RunCompleted(LocalGatewaySetupState state, TimeSpan duration); + void PhaseStarted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message); + void PhaseCompleted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, TimeSpan duration); + string CommandStarted(string fileName, IReadOnlyList arguments, TimeSpan timeout); + void CommandCompleted(string commandId, string fileName, IReadOnlyList arguments, TimeSpan duration, WslCommandResult result, bool timedOut); + void InstallerEvent(LocalGatewaySetupPhase phase, OpenClawLinuxInstallerEvent installerEvent); + void LifecycleStarted(string operation); + void LifecycleStep(string operation, string step, bool success, string? errorCode = null, string? errorMessage = null); + void LifecycleCompleted(string operation, LocalGatewayLifecycleResult result, TimeSpan duration); + Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default); +} + +public sealed class NullLocalGatewaySetupDiagnosticsSink : ILocalGatewaySetupDiagnosticsSink +{ + public static readonly NullLocalGatewaySetupDiagnosticsSink Instance = new(); + + public string? RunTracePath => null; + public string? LatestTracePath => null; + public string? LatestSummaryPath => null; + + public void RunStarted(LocalGatewaySetupState state, LocalGatewaySetupOptions options) { } + public void RunCompleted(LocalGatewaySetupState state, TimeSpan duration) { } + public void PhaseStarted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message) { } + public void PhaseCompleted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, TimeSpan duration) { } + public string CommandStarted(string fileName, IReadOnlyList arguments, TimeSpan timeout) => string.Empty; + public void CommandCompleted(string commandId, string fileName, IReadOnlyList arguments, TimeSpan duration, WslCommandResult result, bool timedOut) { } + public void InstallerEvent(LocalGatewaySetupPhase phase, OpenClawLinuxInstallerEvent installerEvent) { } + public void LifecycleStarted(string operation) { } + public void LifecycleStep(string operation, string step, bool success, string? errorCode = null, string? errorMessage = null) { } + public void LifecycleCompleted(string operation, LocalGatewayLifecycleResult result, TimeSpan duration) { } + public Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.CompletedTask; +} + +public sealed class LocalGatewaySetupDiagnosticsService : ILocalGatewaySetupDiagnosticsSink +{ + public const int SchemaVersion = 1; + private const int MaxCommandOutputChars = 4096; + private const int MaxStoredRecords = 512; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = false + }; + + private readonly object _lock = new(); + private readonly string _setupLogDirectory; + private readonly List _records = new(); + private string? _runId; + private string? _installId; + private bool _initialized; + + public LocalGatewaySetupDiagnosticsService(string? localDataPath = null) + { + _setupLogDirectory = Path.Combine(localDataPath ?? ResolveLocalDataPath(), "Logs", "Setup"); + LatestTracePath = Path.Combine(_setupLogDirectory, "easy-setup-latest.jsonl"); + LatestSummaryPath = Path.Combine(_setupLogDirectory, "easy-setup-latest.txt"); + } + + public string? RunTracePath { get; private set; } + public string? LatestTracePath { get; } + public string? LatestSummaryPath { get; } + + public static string ResolveLocalDataPath() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 } dataOverride) + return dataOverride; + + if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") is { Length: > 0 } localDataOverride) + return Path.Combine(localDataOverride, "OpenClawTray"); + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray"); + } + + public static string LatestSummaryPathForCurrentUser() => + Path.Combine(ResolveLocalDataPath(), "Logs", "Setup", "easy-setup-latest.txt"); + + public static string SetupStatePathForCurrentUser() => + Path.Combine(ResolveLocalDataPath(), "setup-state.json"); + + public void RunStarted(LocalGatewaySetupState state, LocalGatewaySetupOptions options) + { + EnsureRunInitialized(state); + Write(new SetupDiagnosticRecord( + Event: "run_started", + Level: "info", + RunId: state.RunId, + InstallId: state.InstallId, + Status: state.Status.ToString(), + Message: "Local easy-button setup started.", + Details: new Dictionary + { + ["distro_name"] = options.DistroName, + ["gateway_url"] = SetupDiagnosticsRedactor.SanitizeText(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options)), + ["gateway_port"] = options.GatewayPort, + ["openclaw_install_version"] = options.OpenClawInstallVersion, + ["allow_existing_distro"] = options.AllowExistingDistro, + ["enable_windows_tray_node"] = options.EnableWindowsTrayNodeByDefault, + ["os_version"] = Environment.OSVersion.VersionString, + ["process_architecture"] = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(), + ["os_architecture"] = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString(), + ["dotnet_runtime"] = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + ["is_64_bit_os"] = Environment.Is64BitOperatingSystem, + ["is_elevated"] = IsElevated(), + ["setup_state_path"] = SetupStatePathForCurrentUser(), + ["tray_log_path"] = Logger.LogFilePath + })); + } + + public void RunCompleted(LocalGatewaySetupState state, TimeSpan duration) + { + EnsureRunInitialized(state); + var failed = state.Status is LocalGatewaySetupStatus.FailedRetryable + or LocalGatewaySetupStatus.FailedTerminal + or LocalGatewaySetupStatus.Blocked; + Write(new SetupDiagnosticRecord( + Event: failed ? "run_failed" : "run_completed", + Level: failed ? "error" : "info", + RunId: state.RunId, + InstallId: state.InstallId, + Phase: state.Phase.ToString(), + VisibleStage: GetVisibleStage(state.Phase), + Status: state.Status.ToString(), + Message: state.UserMessage, + FailureCode: state.FailureCode, + Retryable: state.Status == LocalGatewaySetupStatus.FailedRetryable, + DurationMs: duration.TotalMilliseconds, + Details: BuildStateDetails(state))); + WriteSummary(state, duration); + } + + public void PhaseStarted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message) + { + EnsureRunInitialized(state); + Write(new SetupDiagnosticRecord( + Event: "phase_started", + Level: "info", + RunId: state.RunId, + InstallId: state.InstallId, + Phase: phase.ToString(), + VisibleStage: GetVisibleStage(phase), + Status: state.Status.ToString(), + Message: message)); + } + + public void PhaseCompleted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, TimeSpan duration) + { + EnsureRunInitialized(state); + var failed = state.Status is LocalGatewaySetupStatus.FailedRetryable + or LocalGatewaySetupStatus.FailedTerminal + or LocalGatewaySetupStatus.Blocked + or LocalGatewaySetupStatus.Cancelled; + Write(new SetupDiagnosticRecord( + Event: failed ? "phase_failed" : "phase_succeeded", + Level: failed ? "error" : "info", + RunId: state.RunId, + InstallId: state.InstallId, + Phase: phase.ToString(), + VisibleStage: GetVisibleStage(phase), + Status: state.Status.ToString(), + Message: failed ? state.UserMessage : message, + FailureCode: state.FailureCode, + Retryable: state.Status == LocalGatewaySetupStatus.FailedRetryable, + DurationMs: duration.TotalMilliseconds, + Details: failed ? BuildStateDetails(state) : null)); + + if (failed) + WriteSummary(state, duration); + } + + public string CommandStarted(string fileName, IReadOnlyList arguments, TimeSpan timeout) + { + var commandId = Guid.NewGuid().ToString("N")[..12]; + Write(new SetupDiagnosticRecord( + Event: "command_started", + Level: "debug", + RunId: _runId, + InstallId: _installId, + Message: fileName, + Details: new Dictionary + { + ["command_id"] = commandId, + ["file_name"] = fileName, + ["arguments"] = SetupDiagnosticsRedactor.RedactArguments(arguments), + ["timeout_ms"] = timeout.TotalMilliseconds + })); + return commandId; + } + + public void CommandCompleted(string commandId, string fileName, IReadOnlyList arguments, TimeSpan duration, WslCommandResult result, bool timedOut) + { + var stdout = SetupDiagnosticsRedactor.SanitizeCommandOutput(result.StandardOutput, MaxCommandOutputChars, out var stdoutTruncated); + var stderr = SetupDiagnosticsRedactor.SanitizeCommandOutput(result.StandardError, MaxCommandOutputChars, out var stderrTruncated); + Write(new SetupDiagnosticRecord( + Event: result.Success && !timedOut ? "command_succeeded" : "command_failed", + Level: result.Success && !timedOut ? "debug" : "warn", + RunId: _runId, + InstallId: _installId, + Message: fileName, + DurationMs: duration.TotalMilliseconds, + Details: new Dictionary + { + ["command_id"] = commandId, + ["file_name"] = fileName, + ["arguments"] = SetupDiagnosticsRedactor.RedactArguments(arguments), + ["exit_code"] = result.ExitCode, + ["timed_out"] = timedOut, + ["stdout"] = stdout, + ["stdout_truncated"] = stdoutTruncated, + ["stderr"] = stderr, + ["stderr_truncated"] = stderrTruncated + })); + } + + public void InstallerEvent(LocalGatewaySetupPhase phase, OpenClawLinuxInstallerEvent installerEvent) + { + Write(new SetupDiagnosticRecord( + Event: "installer_event", + Level: "info", + RunId: _runId, + InstallId: _installId, + Phase: phase.ToString(), + VisibleStage: GetVisibleStage(phase), + Message: SetupDiagnosticsRedactor.SanitizeText(installerEvent.Message ?? installerEvent.RawLine), + Details: new Dictionary + { + ["installer_event"] = SetupDiagnosticsRedactor.SanitizeText(installerEvent.Event), + ["installer_phase"] = SetupDiagnosticsRedactor.SanitizeText(installerEvent.Phase), + ["raw_line"] = SetupDiagnosticsRedactor.SanitizeText(installerEvent.RawLine) + })); + } + + public void LifecycleStarted(string operation) + { + EnsureLifecycleInitialized(operation); + Write(new SetupDiagnosticRecord( + Event: "lifecycle_started", + Level: "info", + RunId: _runId, + InstallId: _installId, + Message: operation)); + } + + public void LifecycleStep(string operation, string step, bool success, string? errorCode = null, string? errorMessage = null) + { + Write(new SetupDiagnosticRecord( + Event: success ? "lifecycle_step_succeeded" : "lifecycle_step_failed", + Level: success ? "info" : "error", + RunId: _runId, + InstallId: _installId, + Message: step, + FailureCode: errorCode, + Details: new Dictionary + { + ["operation"] = operation, + ["step"] = step, + ["error_message"] = SetupDiagnosticsRedactor.SanitizeText(errorMessage) + })); + } + + public void LifecycleCompleted(string operation, LocalGatewayLifecycleResult result, TimeSpan duration) + { + Write(new SetupDiagnosticRecord( + Event: result.Success ? "lifecycle_completed" : "lifecycle_failed", + Level: result.Success ? "info" : "error", + RunId: _runId, + InstallId: _installId, + Message: operation, + FailureCode: result.ErrorCode, + Retryable: !result.Success, + DurationMs: duration.TotalMilliseconds, + Details: new Dictionary + { + ["operation"] = operation, + ["error_message"] = SetupDiagnosticsRedactor.SanitizeText(result.ErrorMessage), + ["steps"] = result.Steps ?? Array.Empty() + })); + WriteLifecycleSummary(operation, result, duration); + } + + public Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + private void EnsureRunInitialized(LocalGatewaySetupState state) + { + lock (_lock) + { + if (_initialized + && string.Equals(_runId, state.RunId, StringComparison.Ordinal) + && string.Equals(_installId, state.InstallId, StringComparison.Ordinal)) + { + return; + } + + Directory.CreateDirectory(_setupLogDirectory); + _runId = state.RunId; + _installId = state.InstallId; + var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss"); + var shortRunId = string.IsNullOrWhiteSpace(state.RunId) + ? Guid.NewGuid().ToString("N")[..12] + : state.RunId[..Math.Min(12, state.RunId.Length)]; + RunTracePath = Path.Combine(_setupLogDirectory, $"setup-{timestamp}-{shortRunId}.jsonl"); + SafeDelete(LatestTracePath); + SafeDelete(LatestSummaryPath); + _records.Clear(); + _initialized = true; + } + } + + private void EnsureLifecycleInitialized(string operation) + { + lock (_lock) + { + Directory.CreateDirectory(_setupLogDirectory); + _runId = Guid.NewGuid().ToString("N"); + _installId = null; + var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss"); + var operationSlug = string.IsNullOrWhiteSpace(operation) + ? "lifecycle" + : string.Concat(operation.Where(char.IsLetterOrDigit)).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(operationSlug)) + operationSlug = "lifecycle"; + RunTracePath = Path.Combine(_setupLogDirectory, $"setup-{timestamp}-{operationSlug}-{_runId[..12]}.jsonl"); + SafeDelete(LatestTracePath); + SafeDelete(LatestSummaryPath); + _records.Clear(); + _initialized = true; + } + } + + private void Write(SetupDiagnosticRecord record) + { + lock (_lock) + { + if (RunTracePath is null || LatestTracePath is null) + return; + + var sanitized = record.Sanitized(); + _records.Add(sanitized); + if (_records.Count > MaxStoredRecords) + _records.RemoveAt(0); + + var line = SetupDiagnosticsRedactor.SanitizeText(JsonSerializer.Serialize(sanitized, s_jsonOptions)) ?? "{}"; + File.AppendAllText(RunTracePath, line + Environment.NewLine, Encoding.UTF8); + File.AppendAllText(LatestTracePath, line + Environment.NewLine, Encoding.UTF8); + } + } + + private void WriteSummary(LocalGatewaySetupState state, TimeSpan duration) + { + lock (_lock) + { + if (LatestSummaryPath is null) + return; + + Directory.CreateDirectory(_setupLogDirectory); + File.WriteAllText(LatestSummaryPath, BuildSummary(state, duration), Encoding.UTF8); + } + } + + private void WriteLifecycleSummary(string operation, LocalGatewayLifecycleResult result, TimeSpan duration) + { + lock (_lock) + { + if (LatestSummaryPath is null) + return; + + Directory.CreateDirectory(_setupLogDirectory); + File.WriteAllText(LatestSummaryPath, BuildLifecycleSummary(operation, result, duration), Encoding.UTF8); + } + } + + private string BuildSummary(LocalGatewaySetupState state, TimeSpan duration) + { + var failed = state.Status is LocalGatewaySetupStatus.FailedRetryable + or LocalGatewaySetupStatus.FailedTerminal + or LocalGatewaySetupStatus.Blocked; + var sb = new StringBuilder(); + sb.AppendLine("OpenClaw easy setup diagnostics"); + sb.AppendLine($"Outcome: {(failed ? "FAILED" : state.Status.ToString().ToUpperInvariant())}"); + if (failed) + { + sb.AppendLine($"Failed phase: {LastRunningPhase(state)}"); + if (!string.IsNullOrWhiteSpace(state.FailureCode)) + sb.AppendLine($"Failure code: {SetupDiagnosticsRedactor.SanitizeText(state.FailureCode)}"); + if (!string.IsNullOrWhiteSpace(state.UserMessage)) + sb.AppendLine($"Message: {SetupDiagnosticsRedactor.SanitizeText(state.UserMessage)}"); + sb.AppendLine($"Retryable: {state.Status == LocalGatewaySetupStatus.FailedRetryable}"); + } + sb.AppendLine($"Run ID: {state.RunId}"); + sb.AppendLine($"Install ID: {state.InstallId}"); + sb.AppendLine($"Updated UTC: {DateTimeOffset.UtcNow:O}"); + sb.AppendLine($"Duration: {duration.TotalSeconds:F1}s"); + sb.AppendLine($"Summary: {LatestSummaryPath}"); + sb.AppendLine($"JSONL trace: {LatestTracePath}"); + sb.AppendLine($"Per-run JSONL trace: {RunTracePath}"); + sb.AppendLine($"Tray log: {Logger.LogFilePath}"); + sb.AppendLine($"Setup state: {SetupStatePathForCurrentUser()}"); + sb.AppendLine(); + sb.AppendLine("Phase timeline:"); + foreach (var record in _records.Where(r => r.Event is "phase_succeeded" or "phase_failed")) + { + var mark = record.Event == "phase_succeeded" ? "OK" : "FAILED"; + var phase = record.Phase ?? "(unknown)"; + var visible = string.IsNullOrWhiteSpace(record.VisibleStage) ? "" : $" [{record.VisibleStage}]"; + var ms = record.DurationMs is null ? "" : $" {record.DurationMs.Value:F0}ms"; + sb.AppendLine($"- {mark} {phase}{visible}{ms} - {SetupDiagnosticsRedactor.SanitizeText(record.Message)}"); + } + if (failed) + { + sb.AppendLine(); + sb.AppendLine("Next actions:"); + foreach (var action in BuildNextActions(state)) + sb.AppendLine($"- {action}"); + } + return sb.ToString(); + } + + private string BuildLifecycleSummary(string operation, LocalGatewayLifecycleResult result, TimeSpan duration) + { + var sb = new StringBuilder(); + sb.AppendLine("OpenClaw easy setup diagnostics"); + sb.AppendLine($"Outcome: {(result.Success ? "COMPLETE" : "FAILED")}"); + sb.AppendLine($"Gateway lifecycle operation: {SetupDiagnosticsRedactor.SanitizeText(operation)}"); + if (!result.Success) + { + if (!string.IsNullOrWhiteSpace(result.ErrorCode)) + sb.AppendLine($"Failure code: {SetupDiagnosticsRedactor.SanitizeText(result.ErrorCode)}"); + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + sb.AppendLine($"Message: {SetupDiagnosticsRedactor.SanitizeText(result.ErrorMessage)}"); + } + sb.AppendLine($"Run ID: {_runId}"); + sb.AppendLine($"Updated UTC: {DateTimeOffset.UtcNow:O}"); + sb.AppendLine($"Duration: {duration.TotalSeconds:F1}s"); + sb.AppendLine($"Summary: {LatestSummaryPath}"); + sb.AppendLine($"JSONL trace: {LatestTracePath}"); + sb.AppendLine($"Per-run JSONL trace: {RunTracePath}"); + sb.AppendLine($"Tray log: {Logger.LogFilePath}"); + sb.AppendLine(); + sb.AppendLine("Lifecycle timeline:"); + foreach (var record in _records.Where(r => r.Event is "lifecycle_step_succeeded" or "lifecycle_step_failed")) + { + var mark = record.Event == "lifecycle_step_succeeded" ? "OK" : "FAILED"; + sb.AppendLine($"- {mark} {SetupDiagnosticsRedactor.SanitizeText(record.Message)}"); + } + if (!result.Success) + { + sb.AppendLine(); + sb.AppendLine("Next actions:"); + sb.AppendLine($"- Open setup JSONL trace: {LatestTracePath}"); + sb.AppendLine($"- Open tray log: {Logger.LogFilePath}"); + if (string.Join(" ", result.ErrorCode, result.ErrorMessage).Contains("wsl", StringComparison.OrdinalIgnoreCase) + || string.Join(" ", result.ErrorCode, result.ErrorMessage).Contains("gateway", StringComparison.OrdinalIgnoreCase)) + { + sb.AppendLine("- If WSL diagnostics are needed, follow aka.ms/wsllogs."); + } + } + return sb.ToString(); + } + + private static Dictionary BuildStateDetails(LocalGatewaySetupState state) + { + return new Dictionary + { + ["issues"] = state.Issues.Select(issue => new Dictionary + { + ["code"] = SetupDiagnosticsRedactor.SanitizeText(issue.Code), + ["message"] = SetupDiagnosticsRedactor.SanitizeText(issue.Message), + ["severity"] = issue.Severity.ToString(), + ["detail"] = SetupDiagnosticsRedactor.SanitizeText(issue.Detail) + }).ToArray(), + ["next_actions"] = BuildNextActions(state) + }; + } + + private static string[] BuildNextActions(LocalGatewaySetupState state) + { + var actions = new List + { + $"Open setup summary: {LatestSummaryPathForCurrentUser()}", + $"Open setup JSONL trace: {Path.Combine(ResolveLocalDataPath(), "Logs", "Setup", "easy-setup-latest.jsonl")}", + $"Open tray log: {Logger.LogFilePath}" + }; + + var text = string.Join(" ", new[] { state.FailureCode, state.UserMessage }.Where(s => !string.IsNullOrWhiteSpace(s))); + if (text.Contains("wsl", StringComparison.OrdinalIgnoreCase) + || text.Contains("gateway", StringComparison.OrdinalIgnoreCase) + || state.Issues.Any(issue => issue.Message.Contains("aka.ms/wsllogs", StringComparison.OrdinalIgnoreCase))) + { + actions.Add("If WSL diagnostics are needed, follow aka.ms/wsllogs."); + } + + return actions.ToArray(); + } + + private static string? GetVisibleStage(LocalGatewaySetupPhase phase) + { + var index = LocalSetupProgressStageMap.IndexOfStageForPhase(phase); + return index >= 0 ? LocalSetupProgressStageMap.VisibleStages[index].LabelKey : null; + } + + private static LocalGatewaySetupPhase LastRunningPhase(LocalGatewaySetupState state) + { + for (var i = state.History.Count - 1; i >= 0; i--) + { + var phase = state.History[i].Phase; + if (phase is not LocalGatewaySetupPhase.Failed + and not LocalGatewaySetupPhase.Cancelled + and not LocalGatewaySetupPhase.NotStarted) + { + return phase; + } + } + + return state.Phase; + } + + private static bool IsElevated() + { + if (!OperatingSystem.IsWindows()) + return false; + + try + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + catch + { + return false; + } + } + + private static void SafeDelete(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return; + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} + +internal sealed record SetupDiagnosticRecord( + [property: JsonPropertyName("event")] string Event, + [property: JsonPropertyName("level")] string Level, + [property: JsonPropertyName("run_id")] string? RunId, + [property: JsonPropertyName("install_id")] string? InstallId, + [property: JsonPropertyName("timestamp_utc")] DateTimeOffset? TimestampUtc = null, + [property: JsonPropertyName("phase")] string? Phase = null, + [property: JsonPropertyName("visible_stage")] string? VisibleStage = null, + [property: JsonPropertyName("status")] string? Status = null, + [property: JsonPropertyName("message")] string? Message = null, + [property: JsonPropertyName("failure_code")] string? FailureCode = null, + [property: JsonPropertyName("retryable")] bool? Retryable = null, + [property: JsonPropertyName("duration_ms")] double? DurationMs = null, + [property: JsonPropertyName("details")] IReadOnlyDictionary? Details = null) +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion => LocalGatewaySetupDiagnosticsService.SchemaVersion; + + public SetupDiagnosticRecord Sanitized() => this with + { + TimestampUtc = TimestampUtc ?? DateTimeOffset.UtcNow, + RunId = SetupDiagnosticsRedactor.SanitizeText(RunId), + InstallId = SetupDiagnosticsRedactor.SanitizeText(InstallId), + Phase = SetupDiagnosticsRedactor.SanitizeText(Phase), + VisibleStage = SetupDiagnosticsRedactor.SanitizeText(VisibleStage), + Status = SetupDiagnosticsRedactor.SanitizeText(Status), + Message = SetupDiagnosticsRedactor.SanitizeText(Message), + FailureCode = SetupDiagnosticsRedactor.SanitizeText(FailureCode), + Details = SetupDiagnosticsRedactor.SanitizeDictionary(Details) + }; +} + +internal static partial class SetupDiagnosticsRedactor +{ + private static readonly string[] SecretValueFlags = + [ + "--token", + "--bootstrap-token", + "--operator-token", + "--device-token", + "--setup-code", + "--password", + "--pass", + "--key", + "--private-key", + "--auth" + ]; + + [GeneratedRegex(@"(?i)(https?|wss?)://([^/\s:@]+):([^@\s/]+)@")] + private static partial Regex UrlCredentialRegex(); + + [GeneratedRegex(@"-----BEGIN [A-Z ]*(?:PRIVATE|PUBLIC) KEY-----.*?-----END [A-Z ]*(?:PRIVATE|PUBLIC) KEY-----", RegexOptions.Singleline)] + private static partial Regex PrivateKeyRegex(); + + [GeneratedRegex(@"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")] + private static partial Regex JwtRegex(); + + [GeneratedRegex(@"(?i)(OPENCLAW_[A-Z0-9_]*(?:TOKEN|SECRET|KEY)|(?:setup[_-]?code|bootstrap[_-]?token|device[_-]?token|gateway[_-]?token|auth[_-]?token|private[_-]?key|password|secret))([^\r\n\S]*[:=][^\r\n\S]*)([^\s,;""'}]+)")] + private static partial Regex KeyValueSecretRegex(); + + public static string? SanitizeText(string? value) + { + if (value is null) + return null; + + var sanitized = value.Replace("\0", string.Empty, StringComparison.Ordinal); + sanitized = PrivateKeyRegex().Replace(sanitized, ""); + sanitized = JwtRegex().Replace(sanitized, ""); + sanitized = UrlCredentialRegex().Replace(sanitized, "$1://@"); + sanitized = KeyValueSecretRegex().Replace(sanitized, "$1$2"); + sanitized = TokenSanitizer.Sanitize(SecretRedactor.Redact(sanitized)); + return sanitized; + } + + public static IReadOnlyList RedactArguments(IReadOnlyList arguments) + { + var redacted = new List(arguments.Count); + var redactNext = false; + foreach (var argument in arguments) + { + if (redactNext) + { + redacted.Add(""); + redactNext = false; + continue; + } + + var equalsIndex = argument.IndexOf('='); + var flagName = equalsIndex > 0 ? argument[..equalsIndex] : argument; + if (IsSecretFlag(flagName)) + { + if (equalsIndex > 0) + redacted.Add(argument[..(equalsIndex + 1)] + ""); + else + { + redacted.Add(argument); + redactNext = true; + } + continue; + } + + redacted.Add(SanitizeText(argument) ?? string.Empty); + } + + return redacted; + } + + public static string? SanitizeCommandOutput(string? value, int maxChars, out bool truncated) + { + truncated = false; + if (string.IsNullOrWhiteSpace(value)) + return null; + + var retained = value; + if (retained.Length > maxChars) + { + retained = retained[^maxChars..]; + truncated = true; + } + + return SanitizeText(retained.Trim()); + } + + public static IReadOnlyDictionary? SanitizeDictionary(IReadOnlyDictionary? details) + { + if (details is null) + return null; + + var sanitized = new Dictionary(StringComparer.Ordinal); + foreach (var pair in details) + sanitized[pair.Key] = SanitizeValue(pair.Value); + return sanitized; + } + + private static object? SanitizeValue(object? value) + { + return value switch + { + null => null, + string s => SanitizeText(s), + IReadOnlyDictionary d => SanitizeDictionary(d), + IEnumerable strings => strings.Select(SanitizeText).ToArray(), + IEnumerable values => values.Select(SanitizeValue).ToArray(), + _ => value + }; + } + + private static bool IsSecretFlag(string flagName) + { + foreach (var flag in SecretValueFlags) + { + if (flagName.Equals(flag, StringComparison.OrdinalIgnoreCase) + || flagName.Contains("token", StringComparison.OrdinalIgnoreCase) + || flagName.Contains("secret", StringComparison.OrdinalIgnoreCase) + || flagName.Contains("password", StringComparison.OrdinalIgnoreCase) + || flagName.Contains("private", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs index 8261562aa..17c3bdebd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs @@ -50,6 +50,9 @@ public sealed record LocalGatewayUninstallPostconditions /// VHD parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\<DistroName>. public bool VhdDirAbsent { get; init; } + /// WSL parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\ + public bool WslParentDirAbsent { get; init; } + /// No gateway records matching local predicate remain in gateways.json. public bool LocalGatewayRecordsAbsent { get; init; } @@ -420,6 +423,35 @@ await RunStepAsync("VHD parent dir cleanup", options, ct, () => return Task.CompletedTask; }); + // ------------------------------------------------------------------ + // Step 5b — WSL parent-dir cleanup (idempotent) + // After the distro-specific VHD dir is removed, clean up the empty + // wsl\ parent directory so the installer leaves no orphaned folders. + // ------------------------------------------------------------------ + await RunStepAsync("WSL parent dir cleanup", options, ct, () => + { + var wslDir = Path.Combine(_localDataPath, "wsl"); + if (!Directory.Exists(wslDir)) + { + RecordStep("WSL parent dir cleanup", UninstallStepStatus.Skipped, + "Directory absent."); + return Task.CompletedTask; + } + + if (!Directory.EnumerateFileSystemEntries(wslDir).Any()) + { + Directory.Delete(wslDir); + RecordStep("WSL parent dir cleanup", UninstallStepStatus.Executed, + "Deleted empty wsl\\ parent directory."); + } + else + { + RecordStep("WSL parent dir cleanup", UninstallStepStatus.Skipped, + "Directory not empty; preserved."); + } + return Task.CompletedTask; + }); + // ------------------------------------------------------------------ // Step 6 — Reset autostart // CRITICAL ORDERING (v3 §B): persist settings BEFORE deleting registry. @@ -783,6 +815,10 @@ private async Task ComputePostconditionsAsy bool vhdDirAbsent = !Directory.Exists( Path.Combine(_localDataPath, "wsl", options.DistroName)); + // WSL parent dir absent? + bool wslParentDirAbsent = !Directory.Exists( + Path.Combine(_localDataPath, "wsl")); + // Local gateway records absent? Reload from disk — fresh instance, not mutated in-memory. bool localRecordsAbsent; try @@ -806,6 +842,7 @@ private async Task ComputePostconditionsAsy McpTokenPreserved = mcpTokenPreserved, KeepalivesAbsent = keepalivesAbsent, VhdDirAbsent = vhdDirAbsent, + WslParentDirAbsent = wslParentDirAbsent, LocalGatewayRecordsAbsent = localRecordsAbsent, LocalGatewayIdentityDirsAbsent = localIdentityDirsAbsent }; diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 7ff87b861..1b2ee2491 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -499,40 +499,33 @@ private void DetachClientHandlers(WindowsNodeClient client) } /// - /// Build the for system.run. Picks - /// wrapping a one-shot AppContainer when MXC is - /// available; falls back to with an explanatory - /// log when it isn't. The choice respects : - /// Required (default) fail-closes; BestEffort uses a host fallback inside MxcCommandRunner; - /// Off bypasses MXC entirely. + /// Build the for system.run. Returns an + /// wrapping . + /// The runner honors + /// and, per issue #494, falls back to + /// at runtime when MXC isn't available on this host. /// private ICommandRunner BuildSystemRunRunner() { var availability = _mxcAvailability ??= MxcAvailability.Probe(_logger); var hostRunner = new LocalCommandRunner(_logger); + var executor = new DirectAppContainerExecutor(availability, _logger); - ISandboxExecutor executor; - if (!availability.HasAnyBackend || availability.RunCommandScriptPath is null) + if (availability.HasAnyBackend) { - // No MXC on this host. We still route through MxcCommandRunner so the - // SystemRunSandboxEnabled toggle is honored: when ON, invocation is - // denied (fail-closed); when OFF, the inner runner falls back to host. - var reason = !availability.HasAnyBackend - ? string.Join("; ", availability.UnsupportedReasons) - : "tools/mxc/run-command.cjs not found"; - executor = new UnavailableSandboxExecutor(reason); - _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable: {reason})"); - } - else - { - executor = new OneShotAppContainerExecutor( - availability, - availability.RunCommandScriptPath, - _logger); _logger.Info( $"[mxc] system.run runner = MxcCommandRunner " + $"(executor={executor.Name}, sandboxEnabled={(_settings?.SystemRunSandboxEnabled ?? true)})"); } + else + { + // MXC unavailable on this host. The runner's top-level + // !_isSandboxAvailable() guard will route to the host fallback + // for every call; the executor is constructed only to satisfy + // the constructor contract and is never invoked. + var reason = string.Join("; ", availability.UnsupportedReasons); + _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable, commands will run uncontained: {reason})"); + } var settingsDirectory = SettingsManager.SettingsDirectoryPath; return new MxcCommandRunner( @@ -611,7 +604,7 @@ private void StartMcpServer() }, _logger, serverName: "openclaw-tray-mcp", - serverVersion: typeof(NodeService).Assembly.GetName().Version?.ToString() ?? "0.0.0"); + serverVersion: AppVersionInfo.Version); // Bearer-token auth. Token is created on first start and persists // alongside other OpenClawTray data (so OPENCLAW_TRAY_DATA_DIR // isolation in tests scopes the token too); CLI/agent registration diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 21bdcea66..32188d38b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -403,6 +403,29 @@ Download & Install + + You're up to date + + + You're running the latest version (v{0}). + + + Couldn't check for updates + + + Something went wrong while checking for updates. + +{0} + + + Update check skipped + + + Update checks are disabled in debug builds. + + + OK + Open Dashboard @@ -816,25 +839,6 @@ Use one of these options: Allow camera recording - - - Screen recording started - - - Screen recording complete - - - Camera recording started - - - Camera recording complete - - - {0} recording requested by agent - - - {0} recording sent to agent - ⚡ New: Activity Stream @@ -1527,9 +1531,6 @@ On your gateway host (Mac/Linux), run: OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 @@ -1665,6 +1666,12 @@ On your gateway host (Mac/Linux), run: Connect to gateway to start chatting + + Waiting for chat to start… + + + The gateway is connected; the chat surface is still coming online. + ⚙️ Config @@ -2877,6 +2884,9 @@ On your gateway host (Mac/Linux), run: Not connected + + Gateway update required — incompatible version + Attach @@ -3126,6 +3136,9 @@ On your gateway host (Mac/Linux), run: Resync + + Back to Connection + 🔗 Pending Operator/Node Pairing diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 47c68f7b2..207884d54 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -374,6 +374,29 @@ Télécharger & Installer + + Vous êtes à jour + + + Vous utilisez la dernière version (v{0}). + + + Impossible de vérifier les mises à jour + + + Une erreur s'est produite lors de la vérification des mises à jour. + +{0} + + + Vérification ignorée + + + La vérification des mises à jour est désactivée dans les builds de débogage. + + + OK + Ouvrir le tableau de bord @@ -772,25 +795,6 @@ Utilisez l'une de ces options : Autoriser l'enregistrement caméra - - - Enregistrement d'écran démarré - - - Enregistrement d'écran terminé - - - Enregistrement caméra démarré - - - Enregistrement caméra terminé - - - Enregistrement {0} demandé par l'agent - - - Enregistrement {0} envoyé à l'agent - ⚡ Nouveau: Fil d'activité @@ -1084,7 +1088,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : OpenClaw a encore besoin d’être configuré avant de pouvoir ouvrir le Hub. Utilisez Retour pour revenir à l’assistant ou aux étapes de connexion, corrigez la configuration manquante, puis réessayez Terminer. - D'accord + OK Bienvenue dans OpenClaw @@ -1478,9 +1482,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 @@ -1616,6 +1617,12 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Connectez-vous à la passerelle pour commencer la discussion + + En attente du démarrage du chat… + + + La passerelle est connectée ; l'interface de chat est encore en cours de démarrage. + ⚙️ Configuration @@ -2828,6 +2835,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Non connecté + + Gateway update required — incompatible version + Joindre @@ -3077,6 +3087,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Resync + + Retour à la connexion + 🔗 Pending Operator/Node Pairing diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c2309fd62..65670f08f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -375,6 +375,29 @@ Downloaden en installeren + + Je bent up-to-date + + + Je gebruikt de nieuwste versie (v{0}). + + + Kan niet op updates controleren + + + Er is iets misgegaan tijdens het controleren op updates. + +{0} + + + Updatecontrole overgeslagen + + + Updatecontroles zijn uitgeschakeld in debug-builds. + + + OK + Dashboard openen @@ -773,25 +796,6 @@ Gebruik een van deze opties: Camera-opname toestaan - - - Schermopname gestart - - - Schermopname voltooid - - - Camera-opname gestart - - - Camera-opname voltooid - - - {0}-opname aangevraagd door agent - - - {0}-opname verzonden naar agent - ⚡ Nieuw: Activiteitenstroom @@ -1085,7 +1089,7 @@ Voer op uw gateway-host (Mac/Linux) uit: OpenClaw heeft nog setup nodig voordat de Hub kan worden geopend. Gebruik Terug om naar de wizard of verbindingsstappen te gaan, los de ontbrekende setup op en probeer daarna Voltooien opnieuw. - Oké + OK Welkom bij OpenClaw @@ -1479,9 +1483,6 @@ Voer op uw gateway-host (Mac/Linux) uit: OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 @@ -1617,6 +1618,12 @@ Voer op uw gateway-host (Mac/Linux) uit: Maak verbinding met de gateway om te chatten + + Wachten op start van de chat… + + + De gateway is verbonden; het chatoppervlak wordt nog geladen. + ⚙️ Configuratie @@ -2829,6 +2836,9 @@ Voer op uw gateway-host (Mac/Linux) uit: Niet verbonden + + Gateway update required — incompatible version + Bijvoegen @@ -3078,6 +3088,9 @@ Voer op uw gateway-host (Mac/Linux) uit: Resync + + Terug naar verbinding + 🔗 Pending Operator/Node Pairing diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index f485e0439..95636b56c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -374,6 +374,29 @@ 下载并安装 + + 已是最新版本 + + + 您正在使用最新版本 (v{0})。 + + + 无法检查更新 + + + 检查更新时出错。 + +{0} + + + 已跳过更新检查 + + + 调试版本已禁用更新检查。 + + + 确定 + 打开仪表板 @@ -772,25 +795,6 @@ 允许摄像头录制 - - - 屏幕录制已开始 - - - 屏幕录制已完成 - - - 摄像头录制已开始 - - - 摄像头录制已完成 - - - {0}录制由代理请求 - - - {0}录制已发送给代理 - ⚡ 新功能: 活动流 @@ -1478,9 +1482,6 @@ OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 @@ -1616,6 +1617,12 @@ 连接到网关以开始聊天 + + 等待聊天启动… + + + 网关已连接;聊天界面仍在上线中。 + ⚙️ 配置 @@ -2828,6 +2835,9 @@ 未连接 + + Gateway update required — incompatible version + 附加 @@ -3077,6 +3087,9 @@ Resync + + 返回连接 + 🔗 Pending Operator/Node Pairing diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index e45c2e503..2c5e84101 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -374,6 +374,29 @@ 下載並安裝 + + 已是最新版本 + + + 您正在使用最新版本 (v{0})。 + + + 無法檢查更新 + + + 檢查更新時發生錯誤。 + +{0} + + + 已略過更新檢查 + + + 偵錯版本已停用更新檢查。 + + + 確定 + 打開儀表板 @@ -772,25 +795,6 @@ 允許攝影機錄製 - - - 螢幕錄製已開始 - - - 螢幕錄製已完成 - - - 攝影機錄製已開始 - - - 攝影機錄製已完成 - - - {0}錄製由代理請求 - - - {0}錄製已傳送給代理 - ⚡ 新功能: 串流活動 @@ -1478,9 +1482,6 @@ OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 @@ -1616,6 +1617,12 @@ 連線到閘道以開始聊天 + + 等待聊天啟動… + + + 閘道已連接;聊天介面仍在上線中。 + ⚙️ 設定 @@ -2828,6 +2835,9 @@ 未連線 + + Gateway update required — incompatible version + 附加 @@ -3077,6 +3087,9 @@ Resync + + 返回連線 + 🔗 Pending Operator/Node Pairing diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index d7373fa9b..58a85237d 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -339,6 +339,62 @@ Search the command palette and return matching commands. ``` Returns array of `{ Title, Subtitle, Icon }`. +## Location (location.*) + +### location.get +Get the device's current geographic location. +``` +{ + "accuracy": "default|high", // optional, default "default" + "maxAge": 30000, // ms; return a cached fix if younger than this + "locationTimeout": 10000 // ms; fail if no fix within this time +} +``` +Returns `{ latitude, longitude, accuracy (meters), timestamp (ms) }`. +Requires the Location capability to be enabled and OS location permission granted to the app. +Error `LOCATION_PERMISSION_REQUIRED` if the user has not granted location access. + +## Device (device.*) + +### device.info +Get static device metadata. No params. +Returns `{ deviceName, modelIdentifier, systemName, systemVersion, appVersion, appBuild, locale }`. + +### device.status +Get live system health data. +``` +{ + "sections": ["os","cpu","memory","disk","battery"] // optional; omit for all +} +``` +Returns a map with `collectedAt` (ISO-8601 string) and one key per section. +Each section may contain `{ error: "collection failed" }` if data was unavailable. +Legacy fields always present: `thermal`, `storage`, `network`, `uptimeSeconds`. + +Battery sub-object: `{ level, state ("charging"|"discharging"|"unknown"), lowPowerModeEnabled }`. + +**Privacy note**: `device.status` reveals battery level, network type, and disk usage. +Agents should request only the sections they need. + +## Browser control proxy (browser.*) + +### browser.proxy +Proxy an HTTP request to the local OpenClaw browser control host (Chrome DevTools Protocol server) running on gateway port + 2. +``` +{ + "path": "/json/list", // required — local control path + "method": "GET", // optional, default GET; allowed: GET|POST|DELETE + "body": {}, // JSON object, for POST/DELETE + "query": {}, // appended as query-string params + "profile": "Default", // optional browser profile name + "timeoutMs": 20000 // optional, max 120000 +} +``` +Returns `{ result, files? }` — `files` is an array of `{ path, base64, mimeType }` if the response referenced local file paths. + +Requires the gateway URL to have an explicit port (e.g. `ws://localhost:8080`). +The browser control host must be running locally on `127.0.0.1:`. + --- ## A2UI v0.8 grammar (for canvas.a2ui.push) diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs index 1383e8235..b1b9dda40 100644 --- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs +++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs @@ -802,6 +802,14 @@ public sealed class FunctionalHostControl : ContentControl, IDisposable private int _renderPending; private bool _disposed; + /// + /// When true, the control will not auto-dispose on Unloaded. + /// Set this when the host lives inside a page with + /// NavigationCacheMode="Enabled" so the component tree + /// survives page navigation. + /// + public bool SuppressAutoDispose { get; set; } + public FunctionalHostControl() { _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); @@ -810,7 +818,7 @@ public FunctionalHostControl() VerticalContentAlignment = VerticalAlignment.Stretch; Background = ThemeResources.ResolveBrush("SolidBackgroundFillColorBaseBrush"); IsTabStop = false; - Unloaded += (_, _) => Dispose(); + Unloaded += (_, _) => { if (!SuppressAutoDispose) Dispose(); }; } public void Mount(Component component) @@ -1425,6 +1433,7 @@ private static GridLength ParseGridLength(string value) return new GridLength(double.Parse(value, CultureInfo.InvariantCulture), GridUnitType.Pixel); } + private void SyncChildren(Panel panel, IReadOnlyList elements, string path, List effects) { var renderedChildren = elements @@ -1448,6 +1457,21 @@ private void SyncChildren(Panel panel, IReadOnlyList elements, string if (ChildrenMatch(panel, desiredChildren)) return; + // Fast path: when panel is empty (e.g. pre-cleared), just add children + // directly. Avoids EnsureChildAt → RemoveFromParent which scans ALL + // cached controls — O(controls × children) with no benefit when + // children have no parent. + if (panel.Children.Count == 0) + { + foreach (var child in desiredChildren) + { + if (child is FrameworkElement { Parent: { } }) + RemoveFromParent(child); + panel.Children.Add(child); + } + return; + } + var desiredSet = new HashSet(desiredChildren); for (var i = panel.Children.Count - 1; i >= 0; i--) { diff --git a/tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs b/tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs new file mode 100644 index 000000000..891064d22 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using OpenClaw.Shared; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public class DeepLinkParserTests +{ + // ─── ParseDeepLink ──────────────────────────────────────────────────────── + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseDeepLink_ReturnsNull_ForNullOrWhitespace(string? uri) + { + Assert.Null(DeepLinkParser.ParseDeepLink(uri)); + } + + [Theory] + [InlineData("https://example.com/send")] + [InlineData("opencla://send")] + [InlineData("OPENCLAW//send")] + public void ParseDeepLink_ReturnsNull_ForNonOpenClawScheme(string uri) + { + Assert.Null(DeepLinkParser.ParseDeepLink(uri)); + } + + [Theory] + [InlineData("openclaw://send", "send")] + [InlineData("OPENCLAW://send", "send")] + [InlineData("openclaw://send/", "send")] + [InlineData("openclaw://send/?text=hello", "send")] + [InlineData("openclaw://send?text=hello", "send")] + [InlineData("openclaw://pair/setup", "pair/setup")] + [InlineData("openclaw://", "")] + public void ParseDeepLink_ExtractsPath(string uri, string expectedPath) + { + var result = DeepLinkParser.ParseDeepLink(uri); + + Assert.NotNull(result); + Assert.Equal(expectedPath, result.Path); + } + + [Fact] + public void ParseDeepLink_ExtractsQueryString() + { + var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello&target=channel"); + + Assert.NotNull(result); + Assert.Equal("text=hello&target=channel", result.Query); + } + + [Fact] + public void ParseDeepLink_ParsesSingleParameter() + { + var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello"); + + Assert.NotNull(result); + Assert.Equal("hello", result.Parameters["text"]); + } + + [Fact] + public void ParseDeepLink_ParsesMultipleParameters() + { + var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello&target=channel&urgent=true"); + + Assert.NotNull(result); + Assert.Equal("hello", result.Parameters["text"]); + Assert.Equal("channel", result.Parameters["target"]); + Assert.Equal("true", result.Parameters["urgent"]); + } + + [Fact] + public void ParseDeepLink_ParameterLookupIsCaseInsensitive() + { + var result = DeepLinkParser.ParseDeepLink("openclaw://send?Text=hello"); + + Assert.NotNull(result); + Assert.Equal("hello", result.Parameters["text"]); + Assert.Equal("hello", result.Parameters["TEXT"]); + } + + [Fact] + public void ParseDeepLink_DecodesUrlEncodedParameters() + { + var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello%20world&key=a%2Bb"); + + Assert.NotNull(result); + Assert.Equal("hello world", result.Parameters["text"]); + Assert.Equal("a+b", result.Parameters["key"]); + } + + [Fact] + public void ParseDeepLink_ReturnsEmptyParameters_WhenNoQuery() + { + var result = DeepLinkParser.ParseDeepLink("openclaw://send"); + + Assert.NotNull(result); + Assert.Empty(result.Parameters); + Assert.Equal(string.Empty, result.Query); + } + + [Fact] + public void ParseDeepLink_HandlesWindowsCanonicalizedForm_SlashBeforeQuery() + { + // Windows may canonicalize openclaw://send/?args=... — path should be "send", not "send/" + var result = DeepLinkParser.ParseDeepLink("openclaw://send/?text=hello"); + + Assert.NotNull(result); + Assert.Equal("send", result.Path); + Assert.Equal("hello", result.Parameters["text"]); + } + + // ─── GetQueryParam ──────────────────────────────────────────────────────── + + [Theory] + [InlineData(null, "key")] + [InlineData("", "key")] + public void GetQueryParam_ReturnsNull_ForNullOrEmptyQuery(string? query, string key) + { + Assert.Null(DeepLinkParser.GetQueryParam(query, key)); + } + + [Theory] + [InlineData("text=hello", "")] + public void GetQueryParam_ReturnsNull_ForEmptyKey(string query, string key) + { + Assert.Null(DeepLinkParser.GetQueryParam(query, key)); + } + + [Fact] + public void GetQueryParam_ReturnsValue_ForMatchingKey() + { + Assert.Equal("hello", DeepLinkParser.GetQueryParam("text=hello&target=chan", "text")); + } + + [Fact] + public void GetQueryParam_LookupIsCaseInsensitive() + { + Assert.Equal("hello", DeepLinkParser.GetQueryParam("Text=hello", "text")); + Assert.Equal("hello", DeepLinkParser.GetQueryParam("text=hello", "TEXT")); + } + + [Fact] + public void GetQueryParam_DecodesUrlEncodedValue() + { + Assert.Equal("hello world", DeepLinkParser.GetQueryParam("text=hello%20world", "text")); + } + + [Fact] + public void GetQueryParam_ReturnsNull_ForMissingKey() + { + Assert.Null(DeepLinkParser.GetQueryParam("text=hello", "missing")); + } +} diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs new file mode 100644 index 000000000..e3a9af200 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using OpenClaw.Shared; +using OpenClaw.Shared.ExecApprovals; + +namespace OpenClaw.Shared.Tests; + +/// +/// Tests for PR7: ExecApprovalsCoordinator full pipeline. +/// Covers rail 8 (observability), rail 10 (UI-free), rail 17 (concurrency), +/// rail 19 (production wiring inert), env injection guard, and log injection prevention. +/// +public class ExecApprovalsCoordinatorTests : IDisposable +{ + private readonly string _dir; + + public ExecApprovalsCoordinatorTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"oca-coord-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static JsonElement Parse(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + // ["cmd","/c","echo","hello"] reliably resolves cmd.exe on Windows via WellKnownPaths. + // Shell wrapper form: singular resolution succeeds; allowlistResolutions=[] (echo is a builtin). + private static NodeInvokeRequest Req(string argsJson) + => new() { Id = "r1", Command = "system.run", Args = Parse(argsJson) }; + + private static NodeInvokeRequest DefaultReq() + => Req("""{"command":["cmd","/c","echo","hello"]}"""); + + private void WriteStoreFile(string json) + => File.WriteAllText(Path.Combine(_dir, "exec-approvals.json"), json); + + private ExecApprovalsCoordinator MakeCoordinator( + ICanPresentEvaluator? canPresent = null, + IExecApprovalV2PromptHandler? prompt = null, + IOpenClawLogger? logger = null) + { + var log = logger ?? NullLogger.Instance; + return new( + new ExecApprovalsStore(_dir, log), + canPresent ?? AlwaysCannotPresentEvaluator.Instance, + prompt ?? ExecApprovalV2NullPromptHandler.Instance, + log); + } + + // ── 1. No file → SecurityDeny (default-deny on first activation) ────────── + + [Fact] + public async Task NoFile_ReturnsSecurityDeny() + { + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c1"); + Assert.Equal(ExecApprovalV2Code.SecurityDeny, result.Code); + } + + // ── 2. security=full → Allow ────────────────────────────────────────────── + + [Fact] + public async Task SecurityFull_AskOff_ReturnsAllow() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}"""); + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c2"); + Assert.True(result.IsAllow); + } + + // ── 3. security=deny → SecurityDeny ────────────────────────────────────── + + [Fact] + public async Task SecurityDeny_ReturnsSecurityDeny() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"deny"}}"""); + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c3"); + Assert.Equal(ExecApprovalV2Code.SecurityDeny, result.Code); + } + + // ── 4. ask=always, canPresent=false, askFallback=deny → UserDenied ──────── + + [Fact] + public async Task AskAlways_CannotPresent_FallbackDeny_ReturnsUserDenied() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"deny"}}"""); + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c4"); + // FallbackDecision(ExecAsk.Deny) → ExecApprovalDecision.Deny → pass2 step2 → UserDenied + Assert.Equal(ExecApprovalV2Code.UserDenied, result.Code); + Assert.Equal("user-denied", result.Reason); + } + + // ── 5. ask=always, canPresent=false, askFallback=off → Allow ───────────── + + [Fact] + public async Task AskAlways_CannotPresent_FallbackOff_ReturnsAllow() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"off"}}"""); + var log = new CapturingLogger(); + var result = await MakeCoordinator(logger: log).HandleAsync(DefaultReq(), "c5"); + Assert.True(result.IsAllow); + Assert.NotNull(log.LastInfo); + Assert.Contains("fallbackUsed=True", log.LastInfo, StringComparison.Ordinal); + } + + // ── 6. canPresent=true, NullPromptHandler → UserDenied ─────────────────── + + [Fact] + public async Task CanPresent_NullPrompt_ReturnsUserDenied() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var result = await MakeCoordinator( + canPresent: AlwaysCanPresentEvaluator.Instance, + prompt: ExecApprovalV2NullPromptHandler.Instance).HandleAsync(DefaultReq(), "c6"); + Assert.Equal(ExecApprovalV2Code.UserDenied, result.Code); + } + + // ── 7. canPresent=true, AllowOnce → Allow ──────────────────────────────── + + [Fact] + public async Task CanPresent_AllowOnce_ReturnsAllow() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var log = new CapturingLogger(); + var result = await MakeCoordinator( + canPresent: AlwaysCanPresentEvaluator.Instance, + prompt: new FixedDecisionPromptHandler(ExecApprovalPromptOutcome.AllowOnce), + logger: log).HandleAsync(DefaultReq(), "c7"); + Assert.True(result.IsAllow); + Assert.Contains("promptAttempted=True", log.LastInfo!, StringComparison.Ordinal); + Assert.DoesNotContain("fallbackUsed=True", log.LastInfo!, StringComparison.Ordinal); + } + + // ── 8. canPresent=true, AllowAlways → Allow ─────────────────────────────── + + [Fact] + public async Task CanPresent_AllowAlways_ReturnsAllow() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var result = await MakeCoordinator( + canPresent: AlwaysCanPresentEvaluator.Instance, + prompt: new FixedDecisionPromptHandler(ExecApprovalPromptOutcome.AllowAlways)) + .HandleAsync(DefaultReq(), "c8"); + Assert.True(result.IsAllow); + } + + // ── 9. Invariant: prompt returns Allow → InternalError ──────────────────── + + [Fact] + public async Task PromptReturnsAllowPlain_ReturnsInternalError() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var result = await MakeCoordinator( + canPresent: AlwaysCanPresentEvaluator.Instance, + prompt: new FixedDecisionPromptHandler(ExecApprovalPromptOutcome.Allow)) + .HandleAsync(DefaultReq(), "c9"); + Assert.Equal(ExecApprovalV2Code.InternalError, result.Code); + Assert.Equal("prompt-returned-allow", result.Reason); + } + + // ── 10. Prompt throws → UserDenied, no fallback ─────────────────────────── + + [Fact] + public async Task PromptThrows_ReturnsUserDenied_FallbackNotUsed() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var log = new CapturingLogger(); + var result = await MakeCoordinator( + canPresent: AlwaysCanPresentEvaluator.Instance, + prompt: new ThrowingPromptHandler(), + logger: log).HandleAsync(DefaultReq(), "c10"); + Assert.Equal(ExecApprovalV2Code.UserDenied, result.Code); + Assert.Equal("prompt-failed", result.Reason); + // Must not delegate to fallback after presenter failure + Assert.Contains("fallbackUsed=False", log.LastWarn!, StringComparison.Ordinal); + } + + // ── 11. Input invalid → ValidationFailed ───────────────────────────────── + + [Fact] + public async Task InvalidInput_ReturnsValidationFailed() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full"}}"""); + var result = await MakeCoordinator().HandleAsync( + Req("""{}"""), "c11"); + Assert.Equal(ExecApprovalV2Code.ValidationFailed, result.Code); + } + + // ── 12. security=allowlist, allowlist empty, ask=off → AllowlistMiss ────── + + [Fact] + public async Task SecurityAllowlist_EmptyList_ReturnsAllowlistMiss() + { + // ["cmd","/c","echo","hello"] → shell wrapper → allowlistResolutions=[] → AllowlistSatisfied=false + WriteStoreFile("""{"version":1,"defaults":{"security":"allowlist","ask":"off"}}"""); + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c12"); + Assert.Equal(ExecApprovalV2Code.AllowlistMiss, result.Code); + } + + // ── 13. FallbackDecision(ask=Always) → Deny, not AllowOnce ─────────────── + + [Fact] + public async Task FallbackDecision_AskFallbackAlways_ReturnsDeny() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"always"}}"""); + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c13"); + // ExecAsk.Always → ExecApprovalDecision.Deny → pass2 → UserDenied (fail-safe) + Assert.False(result.IsAllow); + Assert.NotEqual(ExecApprovalV2Code.Allow, result.Code); + } + + // ── 14. Rail 8 — 7 log fields present ──────────────────────────────────── + + [Fact] + public async Task Rail8_AllSevenLogFieldsPresent() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"deny"}}"""); + var log = new CapturingLogger(); + await MakeCoordinator(logger: log).HandleAsync(DefaultReq(), "corr-14"); + + // security=deny → LogAndReturn → Warn; check all 7 rail-8 fields + Assert.NotNull(log.LastWarn); + var msg = log.LastWarn!; + Assert.Contains("corr-14", msg, StringComparison.Ordinal); + Assert.Contains("path=new", msg, StringComparison.Ordinal); + Assert.Contains("canonical=", msg, StringComparison.Ordinal); + Assert.Contains("decision=deny", msg, StringComparison.Ordinal); + Assert.Contains("reason=", msg, StringComparison.Ordinal); + Assert.Contains("fallbackUsed=", msg, StringComparison.Ordinal); + Assert.Contains("promptAttempted=", msg, StringComparison.Ordinal); + } + + // ── 15. Coordinator not wired in production src ─────────────────────────── + + [Fact] + public void ProductionWiring_CoordinatorNotReferencedInSrc() + { + var repoRoot = FindRepoRoot(); + Assert.NotNull(repoRoot); + var srcDir = Path.Combine(repoRoot, "src"); + var violations = Directory + .GetFiles(srcDir, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.EndsWith("ExecApprovalsCoordinator.cs", StringComparison.OrdinalIgnoreCase)) + .Where(f => File.ReadAllText(f).Contains("ExecApprovalsCoordinator", StringComparison.Ordinal)) + .ToList(); + Assert.Empty(violations); + } + + // ── 16. Rail 10 — coordinator in OpenClaw.Shared, not Tray ─────────────── + + [Fact] + public void Rail10_CoordinatorAssemblyIsOpenClawShared() + { + var asm = typeof(ExecApprovalsCoordinator).Assembly.GetName().Name; + Assert.Equal("OpenClaw.Shared", asm); + } + + // ── 17. Concurrency — 5 simultaneous requests don't corrupt state ───────── + + [Fact] + public async Task Concurrency_FiveConcurrentRequests_AllReturnValidResults() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}"""); + var coordinator = MakeCoordinator(); + var tasks = Enumerable.Range(0, 5) + .Select(i => coordinator.HandleAsync(DefaultReq(), $"conc-{i}")) + .ToList(); + var results = await Task.WhenAll(tasks); + Assert.All(results, r => Assert.NotNull(r)); + Assert.All(results, r => Assert.True(r.IsAllow)); + } + + // ── 18. Env injection → ValidationFailed("env-blocked") ────────────────── + + [Fact] + public async Task EnvInjection_BlockedEnvVar_ReturnsValidationFailed() + { + // security=full,ask=off rules out other denies; env PATH is always blocked + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}"""); + var log = new CapturingLogger(); + var result = await MakeCoordinator(logger: log) + .HandleAsync(Req("""{"command":["cmd","/c","echo","hello"],"env":{"PATH":"C:\\evil"}}"""), "c18"); + + Assert.Equal(ExecApprovalV2Code.ValidationFailed, result.Code); + Assert.Equal("env-blocked", result.Reason); + // Separate Warn with blocked names (emitted before LogAndReturn) + Assert.Contains(log.Warns, w => + w.Contains("env-blocked", StringComparison.Ordinal) && + w.Contains("PATH", StringComparison.Ordinal)); + } + + // ── 19. Log injection — DisplayCommand control chars replaced in log ─────── + + [Fact] + public async Task LogInjection_ControlCharsInCommand_SanitizedInLog() + { + // \r\n in JSON string → actual CR+LF in the parsed command argument + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}"""); + var log = new CapturingLogger(); + await MakeCoordinator(logger: log) + .HandleAsync(Req("""{"command":["cmd","/c","x\r\n[EXEC-APPROVALS] [fake] FAKE"]}"""), "c19"); + + // Should allow (security=full, ask=off) + Assert.NotNull(log.LastInfo); + // CR+LF must not appear literally in the log line + Assert.DoesNotContain("\r\n", log.LastInfo!, StringComparison.Ordinal); + } + + // ── 20. Lock released after prompt throws — second call must not deadlock ──── + + [Fact] + public async Task PromptThrows_LockReleasedForSubsequentCall() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var coordinator = MakeCoordinator( + canPresent: AlwaysCanPresentEvaluator.Instance, + prompt: new ThrowingPromptHandler()); + + var first = await coordinator.HandleAsync(DefaultReq(), "lock-1"); + Assert.Equal(ExecApprovalV2Code.UserDenied, first.Code); + + // Second call must complete — if lock was not released this would deadlock + var second = await coordinator.HandleAsync(DefaultReq(), "lock-2"); + Assert.Equal(ExecApprovalV2Code.UserDenied, second.Code); + } + + // ── 21a. Concurrency with actual lock contention ─────────────────────────── + + [Fact] + public async Task Concurrency_PromptPathWithLockContention_AllReturnValidResults() + { + // ask=always + canPresent=true → all requests enter the locked block + // NullPromptHandler returns Deny → all should be UserDenied (no deadlock, no corruption) + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var coordinator = MakeCoordinator(canPresent: AlwaysCanPresentEvaluator.Instance); + var tasks = Enumerable.Range(0, 5) + .Select(i => coordinator.HandleAsync(DefaultReq(), $"cont-{i}")) + .ToList(); + var results = await Task.WhenAll(tasks); + Assert.All(results, r => Assert.NotNull(r)); + // NullPromptHandler returns Deny → UserDenied for all + Assert.All(results, r => Assert.Equal(ExecApprovalV2Code.UserDenied, r.Code)); + } + + // ── 22a. ExecApprovalV2Result — new codes constructible (InternalError, Allow) ── + + [Fact] + public void V2Result_InternalError_CodeAndReason() + { + var r = ExecApprovalV2Result.InternalError("invariant-violation"); + Assert.Equal(ExecApprovalV2Code.InternalError, r.Code); + Assert.Equal("invariant-violation", r.Reason); + Assert.False(r.IsAllow); + } + + [Fact] + public void V2Result_Allow_IsAllowTrueAndReasonApproved() + { + var r = ExecApprovalV2Result.Allow(); + Assert.Equal(ExecApprovalV2Code.Allow, r.Code); + Assert.Equal("approved", r.Reason); + Assert.True(r.IsAllow); + } + + [Fact] + public void V2Result_IsAllow_FalseForAllDenyCodes() + { + Assert.False(ExecApprovalV2Result.SecurityDeny("x").IsAllow); + Assert.False(ExecApprovalV2Result.UserDenied("x").IsAllow); + Assert.False(ExecApprovalV2Result.ValidationFailed("x").IsAllow); + Assert.False(ExecApprovalV2Result.InternalError("x").IsAllow); + } + + // ── 21. ICanPresentEvaluator stubs ──────────────────────────────────────── + + [Fact] + public void AlwaysCannotPresent_AlwaysReturnsFalse() + { + Assert.False(AlwaysCannotPresentEvaluator.Instance.CanPresent(null)); + Assert.False(AlwaysCannotPresentEvaluator.Instance.CanPresent("session-key")); + } + + [Fact] + public void AlwaysCanPresent_AlwaysReturnsTrue() + { + Assert.True(AlwaysCanPresentEvaluator.Instance.CanPresent(null)); + Assert.True(AlwaysCanPresentEvaluator.Instance.CanPresent("session-key")); + } + + // ── 22. Empty correlationId → auto-generated 32-char hex ───────────────── + + [Fact] + public async Task EmptyCorrelationId_AutoGeneratedInLog() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"deny"}}"""); + var log = new CapturingLogger(); + await MakeCoordinator(logger: log).HandleAsync(DefaultReq(), ""); + + Assert.NotNull(log.LastWarn); + // log format: "[EXEC-APPROVALS] [] path=new ..." + // auto-generated correlationId: Guid.NewGuid().ToString("N") → 32 hex chars + var msg = log.LastWarn!; + var second = msg.IndexOf('[', msg.IndexOf(']') + 1) + 1; + var end = msg.IndexOf(']', second); + Assert.True(end > second); + var id = msg[second..end]; + Assert.Equal(32, id.Length); + Assert.True(id.All(c => char.IsAsciiHexDigit(c)), $"Expected 32 hex chars, got: {id}"); + } + + // ── 23. FallbackDecision(OnMiss, AllowlistSatisfied=false) → Deny ───────── + + [Fact] + public async Task FallbackDecision_AskFallbackOnMiss_NotSatisfied_ReturnsDeny() + { + // security=full, ask=always → RequiresPrompt in pass1 + // canPresent=false → FallbackDecision(context, ExecAsk.OnMiss) + // AllowlistSatisfied=false (security=Full, not Allowlist) → Deny + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"on-miss"}}"""); + var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c23"); + Assert.False(result.IsAllow); + } + + // ── 24. Outer safety net — CanPresent throws → InternalError, not exception ─── + + [Fact] + public async Task CanPresent_Throws_ReturnsInternalError_NotException() + { + WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}"""); + var log = new CapturingLogger(); + var result = await MakeCoordinator( + canPresent: new ThrowingCanPresentEvaluator(), + logger: log).HandleAsync(DefaultReq(), "outer-1"); + + Assert.Equal(ExecApprovalV2Code.InternalError, result.Code); + Assert.Equal("unexpected-exception", result.Reason); + Assert.Contains(log.Errors, e => e.Contains("unexpected-exception")); + } + + // ── Test doubles ────────────────────────────────────────────────────────── + + private sealed class FixedDecisionPromptHandler : IExecApprovalV2PromptHandler + { + private readonly ExecApprovalPromptOutcome _outcome; + public FixedDecisionPromptHandler(ExecApprovalPromptOutcome o) => _outcome = o; + public Task PromptAsync( + ExecApprovalV2PromptRequest _, + CancellationToken cancellationToken = default) + => Task.FromResult(_outcome); + } + + private sealed class ThrowingCanPresentEvaluator : ICanPresentEvaluator + { + public bool CanPresent(string? requestSessionKey) + => throw new InvalidOperationException("simulated canPresent crash"); + } + + private sealed class ThrowingPromptHandler : IExecApprovalV2PromptHandler + { + public Task PromptAsync( + ExecApprovalV2PromptRequest _, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException("simulated presenter crash"); + } + + private sealed class CapturingLogger : IOpenClawLogger + { + public List Infos { get; } = []; + public List Warns { get; } = []; + public List Errors { get; } = []; + public string? LastInfo => Infos.Count > 0 ? Infos[^1] : null; + public string? LastWarn => Warns.Count > 0 ? Warns[^1] : null; + public string? LastError => Errors.Count > 0 ? Errors[^1] : null; + public void Info(string m) => Infos.Add(m); + public void Debug(string m) { } + public void Warn(string m) => Warns.Add(m); + public void Error(string m, Exception? _ = null) => Errors.Add(m); + } + + private static string? FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "openclaw-windows-node.slnx"))) + return dir.FullName; + dir = dir.Parent; + } + return null; + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs new file mode 100644 index 000000000..24682c2e7 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Xunit; +using OpenClaw.Shared; +using OpenClaw.Shared.Mxc; + +namespace OpenClaw.Shared.Tests.Mxc; + +/// +/// Unit tests for that don't actually +/// spawn wxc-exec. End-to-end smoke is covered by +/// . +/// +public class DirectAppContainerExecutorTests +{ + private static SandboxExecutionRequest NewRequest() => new( + CapabilityCommand: "system.run", + Args: JsonDocument.Parse("{\"command\":\"echo hi\",\"shell\":\"cmd\"}").RootElement, + Policy: new SandboxPolicy( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: Array.Empty(), + ReadonlyPaths: Array.Empty(), + DeniedPaths: Array.Empty(), + ClearPolicyOnExit: true), + Network: new NetworkPolicy(false, false), + Ui: new UiPolicy(false, ClipboardPolicy.None, false), + TimeoutMs: 30_000), + TimeoutMs: 30_000); + + [Fact] + public async Task ExecuteAsync_AppContainerUnavailable_Throws() + { + var availability = new MxcAvailability( + isAppContainerAvailable: false, + isIsolationSessionAvailable: false, + isWxcExecResolvable: false, + wxcExecPath: null, + unsupportedReasons: new[] { "test reason" }); + var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance); + + var ex = await Assert.ThrowsAsync(() => executor.ExecuteAsync(NewRequest())); + Assert.Contains("test reason", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WxcExecNotResolvable_Throws() + { + var availability = new MxcAvailability( + isAppContainerAvailable: true, + isIsolationSessionAvailable: false, + isWxcExecResolvable: false, + wxcExecPath: null, + unsupportedReasons: Array.Empty()); + var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance); + + var ex = await Assert.ThrowsAsync(() => executor.ExecuteAsync(NewRequest())); + Assert.Contains("wxc-exec.exe not found", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WxcExecPathMissingOnDisk_Throws() + { + var availability = new MxcAvailability( + isAppContainerAvailable: true, + isIsolationSessionAvailable: false, + isWxcExecResolvable: true, + wxcExecPath: "C:\\does\\not\\exist\\wxc-exec.exe", + unsupportedReasons: Array.Empty()); + var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance); + + // MxcExecutor's ctor throws FileNotFoundException → wrapped in SandboxUnavailableException. + await Assert.ThrowsAsync(() => executor.ExecuteAsync(NewRequest())); + } + + [Fact] + public void Name_IsStableForTelemetry() + { + var availability = new MxcAvailability(false, false, false, null, Array.Empty()); + var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance); + Assert.Equal("mxc-direct-appc", executor.Name); + Assert.True(executor.IsContained); + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json new file mode 100644 index 000000000..43c731074 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json @@ -0,0 +1,49 @@ +{ + "version": "0.4.0-alpha", + "containerId": "golden-balanced", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": {}, + "filesystem": { + "readwritePaths": [ + "C:\\Golden\\Scratch" + ], + "readonlyPaths": [ + "C:\\Golden\\Documents", + "C:\\Golden\\Downloads", + "C:\\Golden\\Desktop" + ], + "deniedPaths": [ + "C:\\Golden\\Settings", + "C:\\Golden\\.ssh", + "C:\\Golden\\Chrome", + "C:\\Golden\\Edge", + "C:\\Golden\\Brave", + "C:\\Golden\\Firefox", + "C:\\Golden\\PSReadLine" + ] + }, + "ui": { + "disable": true, + "clipboard": "read", + "injection": false + }, + "network": { + "defaultPolicy": "allow", + "enforcementMode": "capabilities" + }, + "appContainer": { + "leastPrivilege": false, + "capabilities": [ + "internetClient" + ], + "ui": { + "isolation": "container", + "desktopSystemControl": false, + "systemSettings": "none", + "ime": false + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json new file mode 100644 index 000000000..9b41d91f1 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json @@ -0,0 +1,48 @@ +{ + "version": "0.4.0-alpha", + "containerId": "golden-custom", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": {}, + "filesystem": { + "readwritePaths": [ + "C:\\Golden\\Custom", + "C:\\Golden\\Scratch" + ], + "readonlyPaths": [ + "C:\\Golden\\Documents" + ], + "deniedPaths": [ + "C:\\Golden\\Settings", + "C:\\Golden\\.ssh", + "C:\\Golden\\Chrome", + "C:\\Golden\\Edge", + "C:\\Golden\\Brave", + "C:\\Golden\\Firefox", + "C:\\Golden\\PSReadLine" + ] + }, + "ui": { + "disable": true, + "clipboard": "all", + "injection": false + }, + "network": { + "defaultPolicy": "allow", + "enforcementMode": "capabilities" + }, + "appContainer": { + "leastPrivilege": false, + "capabilities": [ + "internetClient" + ], + "ui": { + "isolation": "container", + "desktopSystemControl": false, + "systemSettings": "none", + "ime": false + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json new file mode 100644 index 000000000..21a3a9837 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json @@ -0,0 +1,43 @@ +{ + "version": "0.4.0-alpha", + "containerId": "golden-locked-down", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": {}, + "filesystem": { + "readwritePaths": [ + "C:\\Golden\\Scratch" + ], + "readonlyPaths": [], + "deniedPaths": [ + "C:\\Golden\\Settings", + "C:\\Golden\\.ssh", + "C:\\Golden\\Chrome", + "C:\\Golden\\Edge", + "C:\\Golden\\Brave", + "C:\\Golden\\Firefox", + "C:\\Golden\\PSReadLine" + ] + }, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "capabilities" + }, + "appContainer": { + "leastPrivilege": false, + "capabilities": [], + "ui": { + "isolation": "container", + "desktopSystemControl": false, + "systemSettings": "none", + "ime": false + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json new file mode 100644 index 000000000..0ee7957a0 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json @@ -0,0 +1,48 @@ +{ + "version": "0.4.0-alpha", + "containerId": "golden-permissive", + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false + }, + "process": {}, + "filesystem": { + "readwritePaths": [ + "C:\\Golden\\Documents", + "C:\\Golden\\Downloads", + "C:\\Golden\\Desktop", + "C:\\Golden\\Scratch" + ], + "readonlyPaths": [], + "deniedPaths": [ + "C:\\Golden\\Settings", + "C:\\Golden\\.ssh", + "C:\\Golden\\Chrome", + "C:\\Golden\\Edge", + "C:\\Golden\\Brave", + "C:\\Golden\\Firefox", + "C:\\Golden\\PSReadLine" + ] + }, + "ui": { + "disable": true, + "clipboard": "all", + "injection": false + }, + "network": { + "defaultPolicy": "allow", + "enforcementMode": "capabilities" + }, + "appContainer": { + "leastPrivilege": false, + "capabilities": [ + "internetClient" + ], + "ui": { + "isolation": "container", + "desktopSystemControl": false, + "systemSettings": "none", + "ime": false + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs index 7c098d2df..5e96bff00 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs @@ -38,12 +38,10 @@ public void Probe_Result_IsConsistent() Assert.False(string.IsNullOrWhiteSpace(availability.WxcExecPath)); } - // HasAnyBackend requires: a backend supported, wxc-exec resolvable, AND - // the run-command.cjs bridge script present. All three must be true. + // HasAnyBackend requires: a backend supported AND wxc-exec resolvable. Assert.Equal( (availability.IsAppContainerAvailable || availability.IsIsolationSessionAvailable) - && availability.IsWxcExecResolvable - && availability.RunCommandScriptPath is not null, + && availability.IsWxcExecResolvable, availability.HasAnyBackend); } @@ -56,14 +54,12 @@ public void Constructor_StoresAllFields() isIsolationSessionAvailable: false, isWxcExecResolvable: true, wxcExecPath: "C:\\fake\\wxc-exec.exe", - runCommandScriptPath: "C:\\fake\\run-command.cjs", unsupportedReasons: reasons); Assert.True(availability.IsAppContainerAvailable); Assert.False(availability.IsIsolationSessionAvailable); Assert.True(availability.IsWxcExecResolvable); Assert.Equal("C:\\fake\\wxc-exec.exe", availability.WxcExecPath); - Assert.Equal("C:\\fake\\run-command.cjs", availability.RunCommandScriptPath); Assert.Single(availability.UnsupportedReasons); Assert.True(availability.HasAnyBackend); } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs index d4249ac5b..f2cdc7e7d 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs @@ -7,9 +7,9 @@ namespace OpenClaw.Shared.Tests.Mxc; /// /// End-to-end smoke test for the MxcCommandRunner pipeline. Actually spawns -/// node.exe + run-command.cjs + wxc-exec.exe to run a real shell payload -/// inside an AppContainer. Gated by OPENCLAW_RUN_INTEGRATION=1 so it doesn't -/// run by default on CI; matches the existing LocalCommandRunnerIntegrationTests pattern. +/// wxc-exec.exe to run a real shell payload inside an AppContainer. Gated by +/// OPENCLAW_RUN_INTEGRATION=1 so it doesn't run by default on CI; matches the +/// existing LocalCommandRunnerIntegrationTests pattern. /// /// Additionally skips (passes without running) when MXC is not available on the /// host (e.g. older Windows UBR or wxc-exec.exe missing). Hosts with MXC enabled @@ -17,8 +17,15 @@ namespace OpenClaw.Shared.Tests.Mxc; /// public class MxcCommandRunnerIntegrationTests { - private static MxcCommandRunner? TryBuildRunner(bool sandboxEnabled = true) + private static MxcCommandRunner? TryBuildRunner(bool sandboxEnabled = true, Action? configure = null) { + if (IsGitHubActions()) + { + Console.WriteLine( + "[mxc-integration] SKIPPING: GitHub Actions does not provide reliable MXC/AppContainer filesystem filtering."); + return null; + } + var availability = MxcAvailability.Probe(NullLogger.Instance); if (!availability.HasAnyBackend) { @@ -28,22 +35,31 @@ public class MxcCommandRunnerIntegrationTests return null; } - if (availability.RunCommandScriptPath is null) + if (!IsNtfsBackedPath(AppContext.BaseDirectory, out var testRoot, out var testFormat)) { - Console.WriteLine("[mxc-integration] SKIPPING: tools/mxc/run-command.cjs not resolvable."); + Console.WriteLine( + $"[mxc-integration] SKIPPING: test output path is on {testFormat} volume {testRoot}. " + + "MXC filesystem grants require NTFS-backed paths."); return null; } - var executor = new OneShotAppContainerExecutor( - availability, - availability.RunCommandScriptPath, - new ConsoleLogger()); + if (!string.IsNullOrWhiteSpace(availability.WxcExecPath) + && !IsNtfsBackedPath(availability.WxcExecPath, out var wxcRoot, out var wxcFormat)) + { + Console.WriteLine( + $"[mxc-integration] SKIPPING: wxc-exec.exe is on {wxcFormat} volume {wxcRoot}. " + + "Build or override OPENCLAW_WXC_EXEC from an NTFS-backed output folder."); + return null; + } + + var executor = new DirectAppContainerExecutor(availability, new ConsoleLogger()); var settings = new SettingsData { SystemRunSandboxEnabled = sandboxEnabled, SystemRunAllowOutbound = false, }; + configure?.Invoke(settings); var hostFallback = new LocalCommandRunner(NullLogger.Instance); @@ -125,5 +141,79 @@ public async Task SystemRun_PipelineSmokeTest_WithDenyPaths_ReturnsResult() Assert.True(result.DurationMs > 0, $"Result should have measurable duration: {result.DurationMs}ms"); Assert.False(result.TimedOut, "Should not have timed out"); } + + [IntegrationFact] + public async Task SystemRun_CmdDir_ReadsGrantedCustomFolder() + { + var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "openclaw-mxc-grant-smoke-" + Guid.NewGuid().ToString("N"))).FullName; + await File.WriteAllTextAsync(Path.Combine(dir, "sentinel.txt"), "hello"); + + try + { + if (!IsNtfsBackedPath(dir, out var grantRoot, out var grantFormat)) + { + Console.WriteLine( + $"[mxc-integration] SKIPPING: custom grant path is on {grantFormat} volume {grantRoot}. " + + "MXC filesystem grants require NTFS-backed paths."); + return; + } + + var runner = TryBuildRunner(configure: settings => + { + settings.SandboxCustomFolders = new List + { + new() { Path = dir, Access = SandboxFolderAccess.ReadWrite }, + }; + }); + if (runner is null) return; // skip — MXC unavailable on this host + + var result = await runner.RunAsync(new CommandRequest + { + Command = "dir", + Shell = "cmd", + Cwd = dir, + TimeoutMs = 30_000, + }); + + Assert.True( + result.ExitCode == 0 && result.Stdout.Contains("sentinel.txt", StringComparison.OrdinalIgnoreCase), + $"ExitCode={result.ExitCode}\nStdout={result.Stdout}\nStderr={result.Stderr}\nTimedOut={result.TimedOut}\nDurationMs={result.DurationMs}\nDir={dir}"); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + private static bool IsNtfsBackedPath(string path, out string root, out string format) + { + root = string.Empty; + format = "unknown"; + + try + { + root = Path.GetPathRoot(Path.GetFullPath(path)) ?? string.Empty; + if (string.IsNullOrWhiteSpace(root)) + return false; + + var drive = new DriveInfo(root); + if (!drive.IsReady) + { + format = "not-ready"; + return false; + } + + format = drive.DriveFormat; + return string.Equals(format, "NTFS", StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) + { + format = ex.GetType().Name; + return false; + } + } + + private static bool IsGitHubActions() + => string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs index 6b8787522..a91621b3f 100644 --- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs @@ -34,19 +34,23 @@ private static MxcCommandRunner NewRunner( } [Fact] - public async Task RunAsync_SandboxEnabled_DeniesWhenSandboxUnavailable() + public async Task RunAsync_SandboxEnabled_FallsBackToHostWhenExecutorIsUnavailable() { + // Issue #494: when MXC is enabled but the executor reports unavailable at + // runtime, fall back to host instead of denying — older Windows users + // need their commands to run uncontained, with a warning in the UI. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" }; - var fallback = new FakeCommandRunner(); + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host-ran" }, + }; var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("Sandboxing is enabled", result.Stderr); - Assert.Contains("test reason", result.Stderr); - // Fallback must NOT have been called. - Assert.Null(fallback.LastRequest); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host-ran", result.Stdout); + Assert.NotNull(fallback.LastRequest); } [Fact] @@ -68,12 +72,13 @@ public async Task RunAsync_SandboxDisabled_AlwaysRoutesToHost() } [Fact] - public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOff() + public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff() { - // The UI hides the toggle when MXC is unavailable. A persisted toggle=OFF - // (from a previous run or different machine) must NOT cause the runner to - // silently route to host — the page says "commands blocked" and the - // runner must match that promise. + // Issue #494: on hosts where MXC is unavailable (Windows 10 / old build / + // missing wxc-exec), the agent must still be able to run commands. + // Route through the host runner; the Sandbox page UI shows a clear + // "running uncontained" warning so the user knows the protection + // boundary isn't active. var executor = new FakeSandboxExecutor(); var fallback = new FakeCommandRunner { @@ -87,21 +92,23 @@ public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOff() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("unavailable", result.Stderr, StringComparison.OrdinalIgnoreCase); - Assert.Contains("blocked", result.Stderr, StringComparison.OrdinalIgnoreCase); - // Neither the sandbox executor nor the host fallback should have run. + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.NotNull(fallback.LastRequest); Assert.Null(executor.LastRequest); - Assert.Null(fallback.LastRequest); } [Fact] - public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOn() + public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn() { - // Same as the toggle-off variant but with toggle=ON. The unavailability - // short-circuit should fire BEFORE we get to the executor path. + // Same as the toggle-off variant — the !_isSandboxAvailable() short-circuit + // fires before either the toggle check or the executor path, and both + // routes lead to the host fallback. var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" }; - var fallback = new FakeCommandRunner(); + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host" }, + }; var runner = NewRunner( executor, fallback, @@ -110,9 +117,10 @@ public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOn() var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("unavailable", result.Stderr, StringComparison.OrdinalIgnoreCase); - Assert.Null(fallback.LastRequest); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.NotNull(fallback.LastRequest); + Assert.Null(executor.LastRequest); } [Fact] @@ -180,14 +188,17 @@ public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure() } [Fact] - public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCache() + public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCacheAndFallsBack() { - // When the executor throws SandboxUnavailableException, the runner should - // invoke its invalidate-availability callback so the next command re-probes. - // Handles the case where MXC components were removed between this NodeService - // starting up and the agent invoking a command. + // When the executor throws SandboxUnavailableException at runtime the + // runner invokes its invalidate-availability callback (so the next + // command re-probes) AND falls back to the host runner for this call + // (issue #494 — don't strand the agent). var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "wxc-exec went missing" }; - var fallback = new FakeCommandRunner(); + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host" }, + }; var invalidationCount = 0; var runner = new MxcCommandRunner( executor, @@ -200,9 +211,10 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); Assert.Equal(1, invalidationCount); - Assert.Contains("wxc-exec went missing", result.Stderr); + Assert.NotNull(fallback.LastRequest); } [Fact] @@ -339,7 +351,6 @@ public async Task RunAsync_LogsSandboxSettingsSnapshotAndPolicy() var requestLog = Assert.Single(logger.DebugMessages, m => m.Contains("system.run sandbox request", StringComparison.Ordinal)); Assert.Contains("sandboxSettingsJson=", requestLog); - Assert.Contains("\"securityLevel\":\"Custom\"", requestLog); Assert.Contains("\"systemRunAllowOutbound\":true", requestLog); Assert.Contains("\"sandboxClipboard\":\"both\"", requestLog); Assert.Contains("\"path\":\"C:\\\\Code\\\\repo\"", requestLog); @@ -366,16 +377,25 @@ public async Task RunAsync_PolicyTimeoutCapsAgentTimeout() } [Fact] - public async Task RunAsync_UnavailableExecutor_DeniesWithReason() + public async Task RunAsync_UnavailableExecutor_FallsBackToHost() { - var executor = new UnavailableSandboxExecutor("test: MXC not installed"); - var fallback = new FakeCommandRunner(); + // Issue #494: executor reports unavailable at runtime → fall back to + // host runner with a warning, not a -1 deny. + var executor = new FakeSandboxExecutor + { + ThrowsUnavailable = true, + UnavailableReason = "test: MXC not installed", + }; + var fallback = new FakeCommandRunner + { + Result = new CommandResult { ExitCode = 0, Stdout = "host" }, + }; var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true)); var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" }); - Assert.Equal(-1, result.ExitCode); - Assert.Contains("MXC not installed", result.Stderr); - Assert.Null(fallback.LastRequest); // never delegated to host + Assert.Equal(0, result.ExitCode); + Assert.Equal("host", result.Stdout); + Assert.NotNull(fallback.LastRequest); } } diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs new file mode 100644 index 000000000..32b85ec9d --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs @@ -0,0 +1,492 @@ +using System.Text.Json; +using Xunit; +using OpenClaw.Shared.Mxc; + +namespace OpenClaw.Shared.Tests.Mxc; + +/// +/// Tests for . Includes: +/// +/// Golden tests vs SDK output captured by tools/mxc/dump-sdk-config.cjs. +/// Per-Sandbox-UI-setting audit tests. +/// Round-trip JSON shape (camelCase, no orphan fields). +/// Env scrub case-insensitivity. +/// ResolveToolDirsFromPath via synthetic PATH. +/// +/// +public class MxcConfigBuilderTests +{ + private const string GoldenContainerId = "golden-test"; + + // The harness used these placeholder paths; mirror them here so the C# + // builder's filesystem grants match the golden fixtures. + private static class P + { + public const string Documents = "C:\\Golden\\Documents"; + public const string Downloads = "C:\\Golden\\Downloads"; + public const string Desktop = "C:\\Golden\\Desktop"; + public const string Custom = "C:\\Golden\\Custom"; + public const string Settings = "C:\\Golden\\Settings"; + public const string Ssh = "C:\\Golden\\.ssh"; + public const string Chrome = "C:\\Golden\\Chrome"; + public const string Edge = "C:\\Golden\\Edge"; + public const string Brave = "C:\\Golden\\Brave"; + public const string Firefox = "C:\\Golden\\Firefox"; + public const string PsRead = "C:\\Golden\\PSReadLine"; + public const string Scratch = "C:\\Golden\\Scratch"; + } + + private static readonly string[] AlwaysDenied = + { + P.Settings, P.Ssh, P.Chrome, P.Edge, P.Brave, P.Firefox, P.PsRead, + }; + + private static SandboxPolicy LockedDownPolicy() => new( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: Array.Empty(), + ReadonlyPaths: Array.Empty(), + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(AllowOutbound: false, AllowLocalNetwork: false), + Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.None, AllowInputInjection: false), + TimeoutMs: 30_000); + + private static SandboxPolicy BalancedPolicy() => new( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: Array.Empty(), + ReadonlyPaths: new[] { P.Documents, P.Downloads, P.Desktop }, + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(AllowOutbound: true, AllowLocalNetwork: false), + Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false), + TimeoutMs: 60_000); + + private static SandboxPolicy PermissivePolicy() => new( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { P.Documents, P.Downloads, P.Desktop }, + ReadonlyPaths: Array.Empty(), + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(AllowOutbound: true, AllowLocalNetwork: false), + Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.All, AllowInputInjection: false), + TimeoutMs: 300_000); + + private static SandboxPolicy CustomPolicy() => new( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { P.Custom }, + ReadonlyPaths: new[] { P.Documents }, + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(AllowOutbound: true, AllowLocalNetwork: false), + Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.All, AllowInputInjection: false), + TimeoutMs: 60_000); + + private static SandboxExecutionRequest RequestFor(SandboxPolicy policy) => new( + CapabilityCommand: "system.run", + Args: JsonDocument.Parse("{}").RootElement, + Policy: policy, + TimeoutMs: 0); // explicitly zero so timeout in builder uses request.TimeoutMs=0 → default 30s + + [Theory] + [InlineData("locked-down", "LockedDown")] + [InlineData("balanced", "Balanced")] + [InlineData("permissive", "Permissive")] + [InlineData("custom", "Custom")] + public void BuiltConfig_MatchesSdkGolden(string preset, string presetMethod) + { + SandboxPolicy policy = presetMethod switch + { + "LockedDown" => LockedDownPolicy(), + "Balanced" => BalancedPolicy(), + "Permissive" => PermissivePolicy(), + _ => CustomPolicy(), + }; + + // The golden test reproduces the exact harness recipe: no commandLine, + // no env, no cwd, no PATH-resolved tool dirs. We pass an empty PATH and + // an empty agent env. The harness stripped process.* (commandLine, cwd, + // env, timeout) too; do the same on the C# side before comparing. + var request = RequestFor(policy); + var config = MxcConfigBuilder.Build( + request, + scratchDir: P.Scratch, + containerId: GoldenContainerId, + pathEnvVar: ""); + + // Strip the process bag the harness dropped, plus normalize containerId. + var stripped = config with + { + ContainerId = $"golden-{preset}", + Process = config.Process with { CommandLine = "", Cwd = null, Env = null, TimeoutMs = null }, + Filesystem = config.Filesystem is null ? null : config.Filesystem with + { + ReadonlyPaths = config.Filesystem.ReadonlyPaths? + .Where(p => !IsDriveRoot(p)) + .ToArray(), + }, + }; + + var actualJson = JsonSerializer.Serialize(stripped, new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }); + + // Drop "commandLine":"" because the SDK output has the process bag completely empty. + using var actualDoc = JsonDocument.Parse(actualJson); + var actualObj = JsonObjectNode.FromJsonElement(actualDoc.RootElement); + if (actualObj.Contains("process") && actualObj["process"] is JsonObjectNode procNode && + procNode.TryGetString("commandLine", out var cmd) && cmd == string.Empty) + { + procNode.Remove("commandLine"); + } + + var goldenPath = ResolveGoldenPath(preset); + var expectedJson = File.ReadAllText(goldenPath); + using var expectedDoc = JsonDocument.Parse(expectedJson); + var expectedObj = JsonObjectNode.FromJsonElement(expectedDoc.RootElement); + + // Deep structural equality, tolerant of property order. + AssertJsonEqual(expectedObj, actualObj, path: "$"); + } + + private static string ResolveGoldenPath(string preset) + { + var local = Path.Combine(AppContext.BaseDirectory, "Mxc", "Golden", $"sdk-config-{preset}.json"); + if (File.Exists(local)) return local; + // Fallback: walk up from the assembly dir to find the source path. + var dir = AppContext.BaseDirectory; + for (int i = 0; i < 6 && dir is not null; i++) + { + var probe = Path.Combine(dir, "tests", "OpenClaw.Shared.Tests", "Mxc", "Golden", $"sdk-config-{preset}.json"); + if (File.Exists(probe)) return probe; + dir = Directory.GetParent(dir)?.FullName; + } + throw new FileNotFoundException($"Golden not found for preset {preset}"); + } + + [Fact] + public void Build_OutboundOn_AddsInternetClientCapability() + { + var policy = BalancedPolicy(); + var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + Assert.Contains("internetClient", config.AppContainer!.Capabilities!); + Assert.Equal("allow", config.Network!.DefaultPolicy); + } + + [Fact] + public void Build_OutboundOff_OmitsInternetClient_AndNetworkBlocks() + { + var policy = LockedDownPolicy(); + var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + Assert.DoesNotContain("internetClient", config.AppContainer!.Capabilities!); + Assert.Equal("block", config.Network!.DefaultPolicy); + } + + [Theory] + [InlineData(ClipboardPolicy.None, "none")] + [InlineData(ClipboardPolicy.Read, "read")] + [InlineData(ClipboardPolicy.Write, "write")] + [InlineData(ClipboardPolicy.All, "all")] + public void Build_ClipboardMode_RoundTripsToWxcExecString(ClipboardPolicy mode, string expected) + { + var policy = LockedDownPolicy() with { Ui = new UiPolicy(false, mode, false) }; + var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + Assert.Equal(expected, config.Ui!.Clipboard); + } + + [Fact] + public void Build_AddsScratchDirToReadwritePaths() + { + var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: ""); + Assert.Contains(P.Scratch, config.Filesystem!.ReadwritePaths!); + } + + [Fact] + public void Build_OverridesTempEnvVarsToScratch() + { + var request = RequestFor(BalancedPolicy()) with + { + Env = new Dictionary + { + ["TEMP"] = "C:\\real-temp", + ["TMP"] = "C:\\real-tmp", + ["TMPDIR"] = "C:\\real-tmpdir", + }, + }; + var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + var env = config.Process.Env!; + Assert.Contains($"TEMP={P.Scratch}", env); + Assert.Contains($"TMP={P.Scratch}", env); + Assert.Contains($"TMPDIR={P.Scratch}", env); + } + + [Fact] + public void Build_AutoGrantsCwdAsReadonly_WhenNotAlreadyCovered() + { + var request = RequestFor(BalancedPolicy()) with { Cwd = "C:\\unrelated\\workdir" }; + var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + Assert.Contains("C:\\unrelated\\workdir", config.Filesystem!.ReadonlyPaths!); + Assert.DoesNotContain("C:\\unrelated\\workdir", config.Filesystem!.ReadwritePaths!); + } + + [Fact] + public void Build_DoesNotDowngradeCwd_WhenAlreadyCoveredByReadwrite() + { + var policy = PermissivePolicy(); // Documents already in readwrite + var request = RequestFor(policy) with { Cwd = Path.Combine(P.Documents, "subfolder") }; + var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + + Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadonlyPaths!); + Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadwritePaths!); + } + + [Fact] + public void Build_DoesNotAutoGrantCwd_WhenAlreadyCovered() + { + var policy = BalancedPolicy(); // Documents already in readonly + var request = RequestFor(policy) with { Cwd = Path.Combine(P.Documents, "subfolder") }; + var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + // Should not have added the subfolder explicitly (parent already grants). + Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadonlyPaths!); + } + + [Fact] + public void Build_DoesNotAutoGrantCwd_WhenOverlapsDenied() + { + var policy = BalancedPolicy(); + var request = RequestFor(policy) with { Cwd = Path.Combine(P.Ssh, "keys") }; + var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: ""); + Assert.DoesNotContain(Path.Combine(P.Ssh, "keys"), config.Filesystem!.ReadonlyPaths!); + } + + [Fact] + public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs() + { + // Synthesize an existing dir on PATH; ensure it shows up as a readonly + // grant candidate. No tool-name filter — every existing PATH dir + // counts (mirrors the SDK's getAvailableToolsPolicy behavior). + var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-tool-test-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: tempDir); + Assert.Contains(tempDir, dirs); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void Build_SynthesizesPathEnvFromGrantedPathDirs() + { + var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-path-env-test-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: tempDir); + Assert.Contains($"PATH={tempDir}", config.Process.Env!); + Assert.Contains(tempDir, config.Filesystem!.ReadonlyPaths!); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public void Build_AddsDriveRootReadonlyForGrantedFolderTraversal() + { + var policy = new SandboxPolicy( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { "C:\\workspace\\out" }, + ReadonlyPaths: Array.Empty(), + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(false, false), + Ui: new UiPolicy(false, ClipboardPolicy.None, false), + TimeoutMs: 30_000); + + var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + Assert.Contains("C:\\", config.Filesystem!.ReadonlyPaths!); + } + + [Fact] + public void Build_DoesNotTreatReadwriteChildAsCoveringParentCwd() + { + var policy = new SandboxPolicy( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { "C:\\workspace\\out" }, + ReadonlyPaths: Array.Empty(), + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(false, false), + Ui: new UiPolicy(false, ClipboardPolicy.None, false), + TimeoutMs: 30_000); + + var config = MxcConfigBuilder.Build(RequestFor(policy) with { Cwd = "C:\\workspace" }, P.Scratch, pathEnvVar: ""); + Assert.Contains("C:\\workspace", config.Filesystem!.ReadonlyPaths!); + Assert.DoesNotContain("C:\\workspace", config.Filesystem!.ReadwritePaths!); + } + + [Fact] + public void ResolvePathDirsForReadonly_SkipsNonExistentDirs() + { + var fake = Path.Combine(Path.GetTempPath(), "definitely-not-real-xyzqq-" + Guid.NewGuid().ToString("N")); + var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: fake); + Assert.Empty(dirs); + } + + [Fact] + public void ResolvePathDirsForReadonly_SkipsDriveRoots() + { + var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: "C:\\"); + Assert.Empty(dirs); + } + + [Fact] + public void Build_DefensiveFilterStripsAllowEntriesOverlappingDenied() + { + // Caller (somehow) provides a custom RW folder pointing inside ~/.ssh. + var policy = new SandboxPolicy( + Version: MxcPolicyBuilder.SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: new[] { Path.Combine(P.Ssh, "keys") }, + ReadonlyPaths: new[] { Path.Combine(P.Chrome, "Profile 1") }, + DeniedPaths: AlwaysDenied, + ClearPolicyOnExit: true), + Network: new NetworkPolicy(false, false), + Ui: new UiPolicy(false, ClipboardPolicy.None, false), + TimeoutMs: 30_000); + var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: ""); + Assert.DoesNotContain(Path.Combine(P.Ssh, "keys"), config.Filesystem!.ReadwritePaths!); + Assert.DoesNotContain(Path.Combine(P.Chrome, "Profile 1"), config.Filesystem!.ReadonlyPaths!); + } + + [Fact] + public void Build_TimeoutDefaultsTo30sWhenRequestZero() + { + var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: ""); + Assert.Equal(30_000, config.Process.TimeoutMs); + } + + [Fact] + public void Build_TimeoutHonorsRequestValue() + { + var req = RequestFor(BalancedPolicy()) with { TimeoutMs = 12_345 }; + var config = MxcConfigBuilder.Build(req, P.Scratch, pathEnvVar: ""); + Assert.Equal(12_345, config.Process.TimeoutMs); + } + + // ---- helpers for tolerant JSON comparison ---- + + private static void AssertJsonEqual(JsonObjectNode expected, JsonObjectNode actual, string path) + { + foreach (var key in expected.Keys) + { + Assert.True(actual.Contains(key), $"Missing key {path}.{key} in actual; actual keys=[{string.Join(",", actual.Keys)}]"); + var ev = expected[key]; + var av = actual[key]; + AssertNodeEqual(ev, av, $"{path}.{key}"); + } + + // Symmetric check: any extra keys on `actual` that aren't in `expected` + // are reported, except for the small allow-list of fields our C# emits + // that the SDK doesn't (and that we explicitly want to surface) and the + // per-invocation random fields we already strip from the golden. + foreach (var key in actual.Keys) + { + if (expected.Contains(key)) continue; + if (IsToleratedExtraKey($"{path}.{key}")) continue; + Assert.Fail($"Unexpected extra key in actual at {path}.{key}; expected keys=[{string.Join(",", expected.Keys)}]"); + } + } + + private static bool IsDriveRoot(string path) + { + try + { + var root = Path.GetPathRoot(path); + if (string.IsNullOrEmpty(root)) return false; + return string.Equals( + Path.TrimEndingDirectorySeparator(path), + Path.TrimEndingDirectorySeparator(root), + StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static bool IsToleratedExtraKey(string fullPath) + { + // commandLine/cwd/env/timeoutMs live under "process" — the SDK leaves + // process empty when called with createConfigFromPolicy and we add + // these ourselves. appContainer.name is a per-invocation random hex + // that we stripped from the goldens. + return fullPath is + "$.process.commandLine" or + "$.process.cwd" or + "$.process.env" or + "$.process.timeoutMs" or + "$.appContainer.name"; + } + + private static void AssertNodeEqual(object? expected, object? actual, string path) + { + switch (expected) + { + case JsonObjectNode eo when actual is JsonObjectNode ao: + AssertJsonEqual(eo, ao, path); break; + case List el when actual is List al: + Assert.True(el.Count == al.Count, $"{path}: expected length {el.Count}, got {al.Count}"); + for (int i = 0; i < el.Count; i++) AssertNodeEqual(el[i], al[i], $"{path}[{i}]"); + break; + default: + Assert.True(Equals(expected, actual) || string.Equals(expected?.ToString(), actual?.ToString(), StringComparison.Ordinal), + $"{path}: expected {expected} ({expected?.GetType().Name ?? "null"}), got {actual} ({actual?.GetType().Name ?? "null"})"); + break; + } + } + + /// Trivial ordered-key JSON object/array tree we can compare and mutate. + private sealed class JsonObjectNode + { + private readonly Dictionary _map = new(StringComparer.Ordinal); + private readonly List _order = new(); + public IEnumerable Keys => _order; + public bool Contains(string key) => _map.ContainsKey(key); + public object? this[string key] { get => _map[key]; set { if (!_map.ContainsKey(key)) _order.Add(key); _map[key] = value; } } + public bool TryGetString(string key, out string value) + { + if (_map.TryGetValue(key, out var v) && v is string s) { value = s; return true; } + value = string.Empty; return false; + } + public void Remove(string key) { _map.Remove(key); _order.Remove(key); } + + public static JsonObjectNode FromJsonElement(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) throw new InvalidOperationException("Expected object root"); + var node = new JsonObjectNode(); + foreach (var prop in root.EnumerateObject()) node[prop.Name] = Convert(prop.Value); + return node; + } + + private static object? Convert(JsonElement el) => el.ValueKind switch + { + JsonValueKind.Object => FromJsonElement(el), + JsonValueKind.Array => el.EnumerateArray().Select(Convert).ToList(), + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt64(out var i) ? (object)i : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }; + } +} diff --git a/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj b/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj index 90b89cbcc..de9e158d8 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj +++ b/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj @@ -4,4 +4,10 @@ + + + PreserveNewest + + + diff --git a/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs b/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs index eccef9bd5..c6fcacb47 100644 --- a/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs +++ b/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs @@ -224,10 +224,14 @@ static void AssertCapped(string text) } [Theory] - [InlineData("Command still running (session foo, pid 1)", "process")] - [InlineData("Process exited with code 0", "process")] + [InlineData("Command still running (session foo, pid 1)", "bash")] + [InlineData("Process exited with code 0", "bash")] [InlineData("Exec completed (oceanic, code 0)", "exec")] [InlineData("OpenClaw 2026.4.23 — Usage: openclaw help", "exec")] + [InlineData(" 1. using System;\n 2. using System.IO;\n 3. namespace Foo;", "view")] + [InlineData("src/Main.cs:42: var x = 1;", "grep")] + [InlineData("commit abc123\nAuthor: User\nDate: Mon Jan 1", "git")] + [InlineData("diff --git a/foo.cs b/foo.cs", "git")] public void ClassifiesKindCorrectly(string text, string expected) { Assert.Equal(expected, OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text)); @@ -240,10 +244,34 @@ public void ClassifiesKindCorrectly(string text, string expected) public void ToolresultRoleAlwaysClassified(string text) { // ``toolresult`` role sidesteps the heuristic — but the kind label - // must still come out as either "exec" or "process" so the chip - // header reads sensibly. Default fallthrough is "exec". + // must still produce a recognized tool type so the chip header reads + // sensibly. Default fallthrough is "exec". var kind = OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text); - Assert.True(kind == "exec" || kind == "process", - $"Unexpected kind '{kind}' for: {text}"); + Assert.False(string.IsNullOrEmpty(kind), $"Empty kind for: {text}"); + } + + // ── Additional classifier edge cases ── + + [Theory] + [InlineData("Cloning into 'repo'...\nremote: Enumerating objects: 100", "exec")] + [InlineData("On branch main\nYour branch is up to date with 'origin/main'.", "exec")] + [InlineData("fatal: not a git repository", "exec")] + [InlineData("src/foo.cs\nsrc/bar.cs\nsrc/baz.cs", "exec")] + [InlineData(" 1. first line\n 2. second line\n 3. third line", "view")] + [InlineData("--- a/src/main.cs\n+++ b/src/main.cs\n@@ -1,3 +1,4 @@", "exec")] + public void ClassifiesAdditionalKindsCorrectly(string text, string expected) + { + Assert.Equal(expected, OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("x")] + public void ClassifyFlattenedToolOutput_EmptyOrTiny_ReturnsExecDefault(string text) + { + // Even empty/tiny text should return a non-empty kind (default "exec") + var kind = OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text); + Assert.False(string.IsNullOrEmpty(kind)); } } diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs new file mode 100644 index 000000000..fd05f223c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs @@ -0,0 +1,154 @@ +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClaw.Tray.Tests; + +public class LocalGatewaySetupDiagnosticsTests +{ + [Fact] + public void Diagnostics_WritesFailureJsonlAndHumanSummary() + { + using var temp = new LocalGatewaySetupTests.TempDirectory(); + var service = new LocalGatewaySetupDiagnosticsService(temp.Path); + var options = new LocalGatewaySetupOptions(); + var state = LocalGatewaySetupState.Create(options); + state.RunId = "run123"; + state.InstallId = "install456"; + + service.RunStarted(state, options); + state.StartPhase(LocalGatewaySetupPhase.Preflight, "Checking your PC"); + service.PhaseStarted(state, LocalGatewaySetupPhase.Preflight, "Checking your PC"); + state.Block( + "wsl_unavailable", + "WSL is unavailable. bootstrapToken: secret-token", + retryable: true, + detail: "gateway-token=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + service.PhaseCompleted(state, LocalGatewaySetupPhase.Preflight, "Checking your PC", TimeSpan.FromMilliseconds(42)); + service.RunCompleted(state, TimeSpan.FromMilliseconds(50)); + + var jsonl = File.ReadAllText(service.LatestTracePath!); + var summary = File.ReadAllText(service.LatestSummaryPath!); + + Assert.Contains("\"schema_version\":1", jsonl); + Assert.Contains("\"event\":\"phase_failed\"", jsonl); + Assert.Contains("\"failure_code\":\"wsl_unavailable\"", jsonl); + Assert.DoesNotContain("secret-token", jsonl); + Assert.DoesNotContain("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", jsonl); + Assert.Contains("Outcome: FAILED", summary); + Assert.Contains("Failed phase: Preflight", summary); + Assert.Contains("Failure code: wsl_unavailable", summary); + Assert.Contains("easy-setup-latest.jsonl", summary); + } + + [Fact] + public void Diagnostics_RedactsCommandArgumentsAndOutputOnDisk() + { + using var temp = new LocalGatewaySetupTests.TempDirectory(); + var service = new LocalGatewaySetupDiagnosticsService(temp.Path); + var options = new LocalGatewaySetupOptions(); + var state = LocalGatewaySetupState.Create(options); + state.RunId = "run-redact"; + state.InstallId = "install-redact"; + service.RunStarted(state, options); + + var secretHex = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + var commandId = service.CommandStarted( + "openclaw", + ["gateway", "status", "--token", "super-secret-token", "--password=super-secret-password"], + TimeSpan.FromSeconds(5)); + service.CommandCompleted( + commandId, + "openclaw", + ["gateway", "status", "--token", "super-secret-token", "--password=super-secret-password"], + TimeSpan.FromMilliseconds(10), + new WslCommandResult( + 1, + $"bootstrapToken: secret-token\nraw token {secretHex}", + "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----"), + timedOut: false); + + var jsonl = File.ReadAllText(service.LatestTracePath!); + + Assert.DoesNotContain("super-secret-token", jsonl); + Assert.DoesNotContain("super-secret-password", jsonl); + Assert.DoesNotContain("secret-token", jsonl); + Assert.DoesNotContain(secretHex, jsonl); + Assert.DoesNotContain("BEGIN PRIVATE KEY", jsonl); + Assert.Contains("", jsonl); + } + + [Fact] + public async Task Engine_WritesRunAndPhaseDiagnostics_ForSuccessfulSetup() + { + using var temp = new LocalGatewaySetupTests.TempDirectory(); + var statePath = Path.Combine(temp.Path, "setup-state.json"); + var wsl = new LocalGatewaySetupTests.FakeWslCommandRunner(); + var provisioning = new FakeProvisioner(); + var diagnostics = new LocalGatewaySetupDiagnosticsService(temp.Path); + var engine = new LocalGatewaySetupEngine( + new LocalGatewaySetupOptions { InstanceInstallLocation = Path.Combine(temp.Path, "OpenClawGateway") }, + new LocalGatewaySetupStateStore(statePath), + new LocalGatewayPreflightProbe(wsl, new LocalGatewaySetupTests.FixedPortProbe(available: true)), + wsl, + new LocalGatewaySetupTests.SuccessfulHealthProbe(), + provisioning, + provisioning, + provisioning, + wslInstanceInstaller: new WslStoreInstanceInstaller(wsl, createDirectory: _ => { }), + wslInstanceConfigurator: new LocalGatewaySetupTests.FakeWslInstanceConfigurator(), + openClawLinuxInstaller: new LocalGatewaySetupTests.FakeOpenClawLinuxInstaller(), + gatewayConfigurationPreparer: new LocalGatewaySetupTests.FakeGatewayConfigurationPreparer(), + gatewayServiceManager: new LocalGatewaySetupTests.FakeGatewayServiceManager(), + diagnosticsSink: diagnostics); + + var state = await engine.RunLocalOnlyAsync(); + + Assert.Equal(LocalGatewaySetupStatus.Complete, state.Status); + var jsonl = File.ReadAllText(diagnostics.LatestTracePath!); + Assert.Contains("\"event\":\"run_started\"", jsonl); + Assert.Contains("\"event\":\"phase_started\"", jsonl); + Assert.Contains("\"event\":\"phase_succeeded\"", jsonl); + Assert.Contains("\"event\":\"run_completed\"", jsonl); + Assert.Contains("\"phase\":\"CreateWslInstance\"", jsonl); + Assert.Contains("Outcome: COMPLETE", File.ReadAllText(diagnostics.LatestSummaryPath!)); + } + + [Fact] + public async Task LifecycleManager_WritesGatewayLifecycleFailureDiagnostics() + { + using var temp = new LocalGatewaySetupTests.TempDirectory(); + var diagnostics = new LocalGatewaySetupDiagnosticsService(temp.Path); + var manager = new LocalGatewayLifecycleManager( + new LocalGatewaySetupOptions(), + new LocalGatewaySetupTests.FakeWslCommandRunner(), + new LocalGatewaySetupTests.SuccessfulHealthProbe(), + diagnosticsSink: diagnostics); + + var result = await manager.RemoveAsync(new LocalGatewayRemoveRequest(ConfirmRemove: false, ClearLocalCredentials: false)); + + Assert.False(result.Success); + var jsonl = File.ReadAllText(diagnostics.LatestTracePath!); + var summary = File.ReadAllText(diagnostics.LatestSummaryPath!); + Assert.Contains("\"event\":\"lifecycle_started\"", jsonl); + Assert.Contains("\"event\":\"lifecycle_step_failed\"", jsonl); + Assert.Contains("\"event\":\"lifecycle_failed\"", jsonl); + Assert.Contains("\"failure_code\":\"confirmation_required\"", jsonl); + Assert.Contains("Gateway lifecycle operation: remove", summary); + Assert.Contains("Failure code: confirmation_required", summary); + } + + private sealed class FakeProvisioner : + IBootstrapTokenProvisioner, IOperatorPairingService, IWindowsTrayNodeProvisioner + { + public Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) => + Task.FromResult(new ProvisioningResult(true)); + + Task IOperatorPairingService.PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) => + Task.FromResult(new ProvisioningResult(true)); + + Task IWindowsTrayNodeProvisioner.CheckReadinessAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) => + Task.FromResult(new ProvisioningResult(true)); + + Task IWindowsTrayNodeProvisioner.PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) => + Task.FromResult(new ProvisioningResult(true)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs index b46937468..f2b07fc15 100644 --- a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs @@ -1435,4 +1435,82 @@ public async Task DryRun_SuccessTrue_PostconditionsSkipped() Assert.True(result.Success); Assert.Empty(result.Errors); } + + // ----------------------------------------------------------------------- + // Test: WslParentDirCleanup — wsl\ dir removed when empty after VHD cleanup + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task WslParentDirCleanup_EmptyAfterVhdCleanup_ExecutedAndDeleted() + { + using var env = new UninstallTestEnv(); + var vhdDir = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(vhdDir); + File.WriteAllText(Path.Combine(vhdDir, "ext4.vhdx"), "fake vhd"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + var wslDir = Path.Combine(env.LocalDataDir, "wsl"); + Assert.False(Directory.Exists(wslDir)); + var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Executed, step.Status); + Assert.True(result.Postconditions.WslParentDirAbsent); + } + + // ----------------------------------------------------------------------- + // Test: WslParentDirCleanup — wsl\ dir preserved when non-empty + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task WslParentDirCleanup_NonEmpty_Skipped() + { + using var env = new UninstallTestEnv(); + var wslDir = Path.Combine(env.LocalDataDir, "wsl"); + Directory.CreateDirectory(wslDir); + // Put an unrelated file in wsl\ to make it non-empty after VHD dir is gone + File.WriteAllText(Path.Combine(wslDir, "other-distro-marker.txt"), "preserved"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + Assert.True(Directory.Exists(wslDir), "wsl\\ dir should be preserved when non-empty"); + var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Skipped, step.Status); + Assert.False(result.Postconditions.WslParentDirAbsent); + } + + // ----------------------------------------------------------------------- + // Test: WslParentDirCleanup — wsl\ dir already absent → Skipped (idempotent) + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task WslParentDirCleanup_AlreadyAbsent_Skipped() + { + using var env = new UninstallTestEnv(); + var wslDir = Path.Combine(env.LocalDataDir, "wsl"); + Assert.False(Directory.Exists(wslDir)); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Skipped, step.Status); + Assert.True(result.Postconditions.WslParentDirAbsent); + } } diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index d10190b4a..b4867f843 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -104,6 +104,8 @@ public class LocalizationValidationTests "PermissionsPage_TtsStatus_DefaultProviderFormat", "PermissionsPage_TtsStatus_ElevenLabsSaved", "PermissionsPage_McpStatus_TokenReadFailedFormat", + // Chat runtime warning seeded English-only until translations land. + "Chat_Composer_Placeholder_IncompatibleGateway", // InstancesPage / ConnectionPage new strings — seeded English across // all locales until translations land. Same precedent as the // PermissionsPage runtime keys above. The Manage expander body reuses @@ -223,6 +225,34 @@ private static bool IsInvariantValue(string value) => value.Contains("https://", StringComparison.Ordinal) || value.Contains("~/", StringComparison.Ordinal); + /// + /// Keys whose value is a Latin-script loanword (e.g. "OK") that reads + /// natively in English/French/Dutch but should still be translated for + /// non-Latin scripts (zh-CN, zh-TW). For these keys the test permits + /// fr-fr and nl-nl to be identical to en-us while zh-cn and zh-tw differ — + /// the "all-or-nothing" rule does not apply. + /// + private static readonly HashSet LatinScriptInvariantResourceKeys = new(StringComparer.Ordinal) + { + "Update_OK", + "Onboarding_IncompleteSetup_Close", + }; + + // Locales whose translations are allowed to remain identical to en-us + // for keys in LatinScriptInvariantResourceKeys (e.g. "OK"). The check in + // Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales requires the + // set of locales sharing the en-us value to *exactly* equal this set. + // + // Pitfall: adding a new Latin-script locale (say de-de) that also uses + // "OK" verbatim will break that test unless de-de is added here too. If + // you add such a locale, update this set; if you add a non-Latin-script + // locale, do nothing. + private static readonly HashSet LatinScriptLocales = new(StringComparer.OrdinalIgnoreCase) + { + "fr-fr", + "nl-nl", + }; + private static bool IsInvariantOrDeferred(string key, string value) => InvariantOrDeferredResourceKeys.Contains(key) || IsInvariantValue(value) @@ -518,6 +548,15 @@ public void Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales() if (identicalLocales.Count != localeResw.Count) { + // Allow Latin-script loanwords (e.g. "OK") to be identical + // across en-us/fr-fr/nl-nl while still being translated for + // non-Latin-script locales (zh-CN, zh-TW). + if (LatinScriptInvariantResourceKeys.Contains(key) + && identicalLocales.All(l => LatinScriptLocales.Contains(l)) + && LatinScriptLocales.All(l => identicalLocales.Contains(l, StringComparer.OrdinalIgnoreCase))) + { + continue; + } partial.Add($"{key} ({enValue}) identical in [{string.Join(", ", identicalLocales)}]"); continue; } diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs index b9d747614..5edb58499 100644 --- a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs +++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs @@ -1,3 +1,4 @@ +using OpenClaw.Shared; using OpenClawTray.Services; namespace OpenClaw.Tray.Tests; @@ -144,4 +145,80 @@ public void DefaultOnCapabilities_OnlyDisabledWhenExplicitlySetToFalse() Assert.False(NodeCapabilityGating.ShouldRegisterBrowserProxy(s)); Assert.False(NodeCapabilityGating.ShouldRegisterSystemRun(s)); } + + // ── GetLocalNodeCapabilities ────────────────────────────────────────────── + + [Fact] + public void GetLocalNodeCapabilities_NullNodes_ReturnsNull() + { + Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(null, "device-1")); + } + + [Fact] + public void GetLocalNodeCapabilities_EmptyNodes_ReturnsNull() + { + Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities([], "device-1")); + } + + [Fact] + public void GetLocalNodeCapabilities_NullDeviceId_ReturnsNull() + { + var nodes = new[] { new GatewayNodeInfo { NodeId = "device-1" } }; + Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(nodes, null)); + } + + [Fact] + public void GetLocalNodeCapabilities_EmptyDeviceId_ReturnsNull() + { + var nodes = new[] { new GatewayNodeInfo { NodeId = "device-1" } }; + Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "")); + } + + [Fact] + public void GetLocalNodeCapabilities_NoMatchingNode_ReturnsNull() + { + var nodes = new[] + { + new GatewayNodeInfo { NodeId = "device-1", Capabilities = ["canvas"] }, + new GatewayNodeInfo { NodeId = "device-2", Capabilities = ["screen"] }, + }; + Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-99")); + } + + [Fact] + public void GetLocalNodeCapabilities_MatchingNode_ReturnsCapabilities() + { + var nodes = new[] + { + new GatewayNodeInfo { NodeId = "device-1", Capabilities = ["canvas", "screen"] }, + new GatewayNodeInfo { NodeId = "device-2", Capabilities = ["location"] }, + }; + var result = NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-1"); + Assert.NotNull(result); + Assert.Equal(new[] { "canvas", "screen" }, result); + } + + [Fact] + public void GetLocalNodeCapabilities_MatchingNodeCaseInsensitive_ReturnsCapabilities() + { + var nodes = new[] + { + new GatewayNodeInfo { NodeId = "Device-ABC", Capabilities = ["canvas"] }, + }; + var result = NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-abc"); + Assert.NotNull(result); + Assert.Equal(new[] { "canvas" }, result); + } + + [Fact] + public void GetLocalNodeCapabilities_NodeWithNoCapabilities_ReturnsEmptyList() + { + var nodes = new[] + { + new GatewayNodeInfo { NodeId = "device-1", Capabilities = [] }, + }; + var result = NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-1"); + Assert.NotNull(result); + Assert.Empty(result); + } } diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 8165914cd..629e5eaea 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -65,6 +65,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 40f5fc8ef..dc0048eb4 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -460,6 +460,43 @@ public async Task LoadAsync_HandshakeKnown_ZeroSessions_ExposesReadyComposeTarge Assert.Equal("agent:main:main", snap.DefaultThreadId); } + [Fact] + public async Task LoadAsync_HandshakeComplete_NoSessionKey_SignalsIncompatibleGateway() + { + // When the gateway completes the handshake but does not advertise + // a mainSessionKey (or sessionDefaults.mainKey), the provider must surface + // an "Incompatible gateway" connection label and a NotReady compose target + // so the UI can show a clear "gateway update required" message rather than + // silently blocking send. Relates to issue #459. + var (bridge, provider, _, _) = CreateProvider(); + bridge.HasHandshakeSnapshot = true; + bridge.MainSessionKey = null; // incompatible gateway: no session key + bridge.RaiseStatus(ConnectionStatus.Connected); + var snap = await provider.LoadAsync(); + + Assert.Equal("Incompatible gateway", snap.ConnectionStatus); + Assert.False(snap.ComposeTarget.IsReady); + Assert.Null(snap.ComposeTarget.SessionKey); + } + + [Fact] + public async Task StatusChanged_IncompatibleGateway_IsReflectedInSnapshotConnectionLabel() + { + // Raise Connected with handshake present but no session key; the snapshot + // must use "Incompatible gateway" rather than the plain "Connected" label. + var (bridge, provider, snapshots, _) = CreateProvider(); + bridge.HasHandshakeSnapshot = true; + bridge.MainSessionKey = null; + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseStatus(ConnectionStatus.Connected); + + Assert.NotEmpty(snapshots); + Assert.Equal("Incompatible gateway", snapshots[^1].ConnectionStatus); + Assert.False(snapshots[^1].ComposeTarget.IsReady); + } + // ── Parity additions: streaming, lifecycle, reasoning, history, abort ── [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs new file mode 100644 index 000000000..55588d6d4 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs @@ -0,0 +1,223 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Chat; +using System.Text.Json; +using Xunit; + +namespace OpenClaw.Tray.Tests; + +/// +/// Tests for the tool metadata cache matching logic used to recover tool +/// names/labels after gateway history flattening. +/// +public class ToolMetaCacheTests +{ + private static OpenClawChatDataProvider.CachedToolMeta Meta(long ts, string tool, string label) => + new() { Ts = ts, ToolName = tool, Label = label }; + + // ── TryMatchCachedTool ── + + [Fact] + public void TryMatch_NullCache_ReturnsNull() + { + Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(null, 1000)); + } + + [Fact] + public void TryMatch_EmptyCache_ReturnsNull() + { + var cache = new Queue(); + Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(cache, 1000)); + } + + [Fact] + public void TryMatch_SingleEntry_DequeuesAndReturns() + { + var cache = new Queue(); + cache.Enqueue(Meta(100, "bash", "ls -la")); + + var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200); + + Assert.NotNull(result); + Assert.Equal("bash", result!.ToolName); + Assert.Equal("ls -la", result.Label); + Assert.Empty(cache); // consumed + } + + [Fact] + public void TryMatch_SequentialOrder_MatchesByPosition() + { + var cache = new Queue(); + cache.Enqueue(Meta(100, "bash", "first")); + cache.Enqueue(Meta(200, "grep", "second")); + cache.Enqueue(Meta(300, "view", "third")); + + // Each call should dequeue the next entry regardless of timestamp + var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 500); + var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600); + var r3 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 700); + + Assert.Equal("bash", r1!.ToolName); + Assert.Equal("grep", r2!.ToolName); + Assert.Equal("view", r3!.ToolName); + Assert.Empty(cache); + } + + [Fact] + public void TryMatch_MoreHistoryThanCache_ReturnsNullWhenExhausted() + { + var cache = new Queue(); + cache.Enqueue(Meta(100, "bash", "only entry")); + + var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200); + var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 300); + + Assert.NotNull(r1); + Assert.Null(r2); // exhausted + } + + [Fact] + public void TryMatch_CachedEntryFarAfterHistory_SkipsMatch() + { + // Cache entry is >5 minutes (300_000ms) after the history entry — + // means this history tool result predates the cache. + var cache = new Queue(); + cache.Enqueue(Meta(500_000, "bash", "future entry")); + + var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000); + + Assert.Null(result); + Assert.Single(cache); // NOT consumed — entry stays for later + } + + [Fact] + public void TryMatch_CachedEntrySlightlyAfterHistory_StillMatches() + { + // Cache entry is <5 min after history — normal SSE delay, should match. + var cache = new Queue(); + cache.Enqueue(Meta(200_000, "bash", "recent entry")); + + var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000); + + Assert.NotNull(result); + Assert.Equal("bash", result!.ToolName); + } + + [Fact] + public void TryMatch_ZeroTimestamps_AlwaysMatch() + { + // When timestamps are 0, the guard is skipped — always dequeue. + var cache = new Queue(); + cache.Enqueue(Meta(0, "bash", "no timestamp")); + + var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 0); + + Assert.NotNull(result); + } + + [Fact] + public void TryMatch_RepeatedToolNames_PreservesOrder() + { + // Multiple entries with the same tool name should be matched in order. + var cache = new Queue(); + cache.Enqueue(Meta(100, "bash", "first bash")); + cache.Enqueue(Meta(200, "bash", "second bash")); + cache.Enqueue(Meta(300, "bash", "third bash")); + + var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 500); + var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600); + + Assert.Equal("first bash", r1!.Label); + Assert.Equal("second bash", r2!.Label); + } + + // ── Constants ── + + [Fact] + public void SessionLimits_AreReasonable() + { + Assert.Equal(20, OpenClawChatDataProvider.MaxCachedSessions); + Assert.Equal(500, OpenClawChatDataProvider.MaxToolEntriesPerSession); + } + + [Fact] + public async Task CacheToolMeta_ConcurrentAdds_FlushesCompleteValidJson() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + var bridge = new FakeBridge + { + History = new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1" + } + }; + var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath); + await provider.LoadHistoryAsync("main"); + + Parallel.For(0, 100, i => + provider.CacheToolMeta("main", 1_000 + i, "bash", $"echo {i}")); + + await provider.DisposeAsync(); + + var json = File.ReadAllText(cachePath); + var cache = JsonSerializer.Deserialize>>(json); + + Assert.NotNull(cache); + Assert.True(cache!.TryGetValue("session-1", out var entries)); + Assert.Equal(100, entries!.Count); + Assert.Empty(Directory.EnumerateFiles(tempDir.DirectoryPath, "*.tmp")); + } + + private sealed class FakeBridge : IChatGatewayBridge + { + public bool IsConnected { get; set; } + public ConnectionStatus CurrentStatus { get; set; } + public string? MainSessionKey { get; set; } + public bool HasHandshakeSnapshot { get; set; } + public ChatHistoryInfo History { get; set; } = new() { SessionKey = "main" }; + + public SessionInfo[] GetSessionList() => Array.Empty(); + public ModelsListInfo? GetCurrentModelsList() => null; + public Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null) => Task.CompletedTask; + public Task PatchSessionModelAsync(string sessionKey, string model) => Task.CompletedTask; + public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => Task.CompletedTask; + public Task RequestChatHistoryAsync(string? sessionKey) => Task.FromResult(History); + public Task SendChatAbortAsync(string runId, string? sessionKey = null) => Task.CompletedTask; + public event EventHandler? StatusChanged; + public event EventHandler? SessionsUpdated; + public event EventHandler? ChatMessageReceived; + public event EventHandler? AgentEventReceived; + public event EventHandler? ModelsListUpdated; + public void RaiseStatus(ConnectionStatus status) => StatusChanged?.Invoke(this, status); + public void RaiseSessions(SessionInfo[] sessions) => SessionsUpdated?.Invoke(this, sessions); + public void RaiseChat(ChatMessageInfo message) => ChatMessageReceived?.Invoke(this, message); + public void RaiseAgent(AgentEventInfo evt) => AgentEventReceived?.Invoke(this, evt); + public void RaiseModels(ModelsListInfo models) => ModelsListUpdated?.Invoke(this, models); + public void Dispose() { } + } + + private sealed class TempDirectory : IDisposable + { + public string DirectoryPath { get; } = Path.Combine(Path.GetTempPath(), "openclaw-tool-meta-" + Guid.NewGuid().ToString("N")); + + public TempDirectory() + { + Directory.CreateDirectory(DirectoryPath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(DirectoryPath)) + Directory.Delete(DirectoryPath, recursive: true); + } + catch + { + // Test cleanup is best-effort. + } + } + } +} diff --git a/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs b/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs index 1d2d0ac27..bea35e8c2 100644 --- a/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs +++ b/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs @@ -320,7 +320,7 @@ private static string BuildDashboardJsonl() // ── Footer row ──────────────────────────────────────────────────── components.Add(Component("ftr", "Row", new() { ["children"] = Children("ftrVer", "ftrDiv", "ftrConn") })); - components.Add(Component("ftrVer", "Text", new() { ["text"] = Lit("v0.4.4"), ["usageHint"] = "caption" })); + components.Add(Component("ftrVer", "Text", new() { ["text"] = Lit("v0.4.7"), ["usageHint"] = "caption" })); components.Add(Component("ftrDiv", "Divider", new() { ["axis"] = "vertical" })); components.Add(Component("ftrConn", "Text", new() { ["text"] = Lit("Connected"), ["usageHint"] = "caption" })); diff --git a/tools/mxc/run-command.cjs b/tools/mxc/run-command.cjs deleted file mode 100644 index dbf37fce8..000000000 --- a/tools/mxc/run-command.cjs +++ /dev/null @@ -1,696 +0,0 @@ -#!/usr/bin/env node -/** - * tools/mxc/run-command.cjs — productized runner for OpenClaw MxcCommandRunner. - * - * Reads a single JSON request from stdin describing a system.run invocation - * plus the SandboxPolicy to apply. Spawns wxc-exec via @microsoft/mxc-sdk's - * spawnSandboxFromConfig({ usePty: false }) so stdout / stderr stay separate - * and the exit code is reliable. Writes a single JSON envelope to stdout on - * completion; node-side errors go to stderr. - * - * Wire request (matches BridgeRequest in OneShotAppContainerExecutor.cs): - * { - * "capabilityCommand": "system.run", - * "args": { command: "...", shell: "powershell"|"cmd"|"pwsh", args?: [], ... }, - * "policy": { version, filesystem, network, ui, timeoutMs }, - * "cwd": "...", "env": {...}, "timeoutMs": 30000, - * "wxcExecPath": "...optional override..." - * } - * - * Wire response (matches BridgeResponse): - * { exitCode, stdout, stderr, timedOut, durationMs, containmentTag } - * - * Currently handles system.run only. Other capabilities follow the same envelope - * shape with capabilityCommand set appropriately and structuredResult populated. - */ - -const { - createConfigFromPolicy, - spawnSandboxFromConfig, - getAvailableToolsPolicy, - getTemporaryFilesPolicy, -} = require('@microsoft/mxc-sdk'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; // mirrors C# DefaultMaxOutputBytes -const HARD_MAX_OUTPUT_BYTES = 256 * 1024 * 1024; // safety ceiling regardless of caller -const DIRECT_DEBUG_LOG_PATH = path.join( - process.env.OPENCLAW_TRAY_DATA_DIR || - process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'OpenClawTray') || - os.tmpdir(), - 'openclaw-mxc-debug.log'); -const DIRECT_DEBUG_DIR = path.dirname(DIRECT_DEBUG_LOG_PATH); -const PREFLIGHT_ONLY = true; - -/** - * Match a drive root like "C:\", "D:", "c:\\", etc. The MXC SDK's - * getAvailableToolsPolicy adds the system drive root as readonly when pwsh.exe - * is on PATH. That's WAY more access than any preset claims to give the agent - * (e.g., Locked Down promises "no standard user folders"). We strip drive - * roots from the merged policy; PATH-specific tool dirs and PSReadLine paths - * stay, so commands still run. - */ -function isDriveRoot(p) { - if (!p) return false; - const norm = path.normalize(p).replace(/[\\/]+$/, ''); - return /^[A-Za-z]:$/.test(norm); -} - -/** - * Match the user's real %TEMP% / %TMP% / os.tmpdir() roots so we can strip - * them from the merged policy. The bridge substitutes a fresh per-invocation - * scratch directory in their place (see createScratchDir below). - */ -function isUserTempRoot(p) { - if (!p) return false; - const norm = path.normalize(p).toLowerCase().replace(/[\\/]+$/, ''); - const candidates = [ - process.env.TEMP, - process.env.TMP, - os.tmpdir(), - ].filter(Boolean).map(c => path.normalize(c).toLowerCase().replace(/[\\/]+$/, '')); - return candidates.some(c => c === norm); -} - -/** - * Remove any allow-list entry that overlaps a denied path. - * Mirrors C# MxcPolicyBuilder.FilterOutDenied. Case-insensitive (NTFS - * semantics) and tolerant of trailing slashes / mixed separators. Returns - * a new array; doesn't mutate the input. - */ -function filterOutDenied(allowed, denied) { - return filterOutDeniedWithReasons(allowed, denied).allowed; -} - -function filterOutDeniedWithReasons(allowed, denied) { - const source = Array.isArray(allowed) ? allowed : []; - if (source.length === 0) return { allowed: [], removed: [] }; - if (!Array.isArray(denied) || denied.length === 0) return { allowed: source, removed: [] }; - const normalizedDenied = denied - .map(d => ({ original: d, normalized: normalizePath(d) })) - .filter(d => d.normalized); - if (normalizedDenied.length === 0) return { allowed: source, removed: [] }; - - const kept = []; - const removed = []; - for (const candidate of source) { - const normalizedCandidate = normalizePath(candidate); - if (!normalizedCandidate) { - removed.push({ path: candidate, reason: 'invalid-path' }); - continue; - } - - const matchedDeny = normalizedDenied.find(d => pathsOverlap(normalizedCandidate, d.normalized)); - if (matchedDeny) { - removed.push({ path: candidate, reason: 'overlaps-denied-path', deniedPath: matchedDeny.original }); - continue; - } - - kept.push(candidate); - } - - return { allowed: kept, removed }; -} - -function buildPathAccounting(callerPolicy, tools, temp, mergedPolicy) { - const callerFs = callerPolicy?.filesystem ?? {}; - const toolReadonly = tools?.readonlyPaths ?? []; - const tempReadwrite = temp?.readwritePaths ?? []; - return { - caller: { - readonlyPaths: callerFs.readonlyPaths ?? [], - readwritePaths: callerFs.readwritePaths ?? [], - deniedPaths: callerFs.deniedPaths ?? [], - }, - sdkAdds: { - toolReadonlyPaths: toolReadonly, - tempReadwritePaths: tempReadwrite, - }, - mergedBeforeScopeFilters: { - readonlyPaths: mergedPolicy?.filesystem?.readonlyPaths ?? [], - readwritePaths: mergedPolicy?.filesystem?.readwritePaths ?? [], - deniedPaths: mergedPolicy?.filesystem?.deniedPaths ?? [], - }, - network: mergedPolicy?.network ?? null, - ui: mergedPolicy?.ui ?? null, - }; -} - -function pathsOverlap(left, right) { - return isSameOrNested(left, right) || isSameOrNested(right, left); -} - -function isSameOrNested(child, parent) { - return child === parent || child.startsWith(parent + '\\') || child.startsWith(parent + '/'); -} - -function normalizePath(p) { - if (!p) return ''; - try { - return path.resolve(p).replace(/[\\/]+$/, '').toLowerCase(); - } catch { - return String(p).toLowerCase(); - } -} - -async function main() { - const startTime = Date.now(); - const req = await readJsonFromStdin(); - - // Args specific to system.run. Other capabilities will have their own shapes. - const args = req.args ?? {}; - const command = typeof args.command === 'string' ? args.command : ''; - const shell = typeof args.shell === 'string' ? args.shell : 'powershell'; - const argv = Array.isArray(args.args) ? args.args : []; - - if (!command) { - return emit(failResponse(-1, 'Missing required arg: command', startTime)); - } - - // Honor the caller-supplied maxOutputBytes (from C# bridge). Clamp to a - // hard ceiling so a misconfigured caller can't OOM the bridge process. - const callerMaxOutput = Number.isFinite(req.maxOutputBytes) && req.maxOutputBytes > 0 - ? Math.min(req.maxOutputBytes, HARD_MAX_OUTPUT_BYTES) - : DEFAULT_MAX_OUTPUT_BYTES; - - // Compose host-discovered tool/temp paths into the policy supplied by C#. - const tools = getAvailableToolsPolicy(process.env, { containerType: 'appcontainer' }); - const temp = getTemporaryFilesPolicy(process.env); - diag('input policy', summarizePolicy(req.policy)); - diag('sdk tools policy', summarizePolicy({ filesystem: tools })); - diag('sdk temp policy', summarizePolicy({ filesystem: temp })); - - const policy = mergePolicy(req.policy, tools, temp); - diag('policy merge path accounting', buildPathAccounting(req.policy, tools, temp, policy)); - - // SCOPE the merged policy: strip the SDK's "convenience" grants that - // bypass the user's explicit choices in the Sandbox UI. - // - Drive root (C:\) — SDK adds this when pwsh.exe is on PATH. Strip it; - // PATH-specific tool dirs (git, node, python, etc.) stay because they - // remain in the filtered list. - // - User's real %TEMP% — wholesale temp access leaks other apps' files. - // We substitute a fresh per-invocation scratch dir as the only writable - // temp area, and override TEMP/TMP/TMPDIR in the spawned process's env - // so commands that write to %TEMP% land in our scratch dir. - const beforeScopeReadonly = policy.filesystem.readonlyPaths || []; - const beforeScopeReadwrite = policy.filesystem.readwritePaths || []; - const driveRootRemovals = beforeScopeReadonly.filter(isDriveRoot); - const tempRootRemovals = beforeScopeReadwrite.filter(isUserTempRoot); - policy.filesystem.readonlyPaths = beforeScopeReadonly.filter(p => !isDriveRoot(p)); - policy.filesystem.readwritePaths = beforeScopeReadwrite.filter(p => !isUserTempRoot(p)); - if (driveRootRemovals.length || tempRootRemovals.length) { - diag('policy scope filtered sdk convenience grants', { - removedReadonlyDriveRoots: driveRootRemovals, - removedReadwriteTempRoots: tempRootRemovals, - }); - } - - // Mirror the C# MxcPolicyBuilder.FilterOutDenied logic on the JS side after - // merging the SDK's tools/temp policies. The C# side already stripped any - // allow-list entry that overlapped a denied path, but the SDK merge re-adds - // its own allow grants (PATH dirs, PSReadLine history, etc.) that the C# - // filter never saw. Without this pass, the SDK could grant access to a - // parent of a denied path — e.g., %LOCALAPPDATA% which contains the browser - // profile dirs we deny in MxcPolicyBuilder. Belt-and-suspenders deny precedence - // independent of the @microsoft/mxc-sdk's (undocumented, alpha) deny semantics. - const readonlyDeniedFilter = filterOutDeniedWithReasons(policy.filesystem.readonlyPaths, policy.filesystem.deniedPaths); - const readwriteDeniedFilter = filterOutDeniedWithReasons(policy.filesystem.readwritePaths, policy.filesystem.deniedPaths); - policy.filesystem.readonlyPaths = readonlyDeniedFilter.allowed; - policy.filesystem.readwritePaths = readwriteDeniedFilter.allowed; - if (readonlyDeniedFilter.removed.length || readwriteDeniedFilter.removed.length) { - diag('policy denied-overlap filtered allow grants', { - removedReadonlyPaths: readonlyDeniedFilter.removed, - removedReadwritePaths: readwriteDeniedFilter.removed, - }); - } - - let scratchDir = null; - try { - scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-mxc-')); - } catch (e) { - return emit(failResponse(-1, `Failed to create scratch dir: ${e.message}`, startTime)); - } - policy.filesystem.readwritePaths.push(scratchDir); - diag('policy scratch grant added', { scratchDir }); - diag('effective policy before config', summarizePolicy(policy)); - - try { - let config; - try { - config = createConfigFromPolicy(policy, 'process'); - diag('sandbox config after policy conversion', summarizeConfig(config)); - } catch (e) { - diag('policy conversion failed', { error: e.message, effectivePolicy: summarizePolicy(policy) }); - return emit(failResponse(-1, `Policy invalid: ${e.message}`, startTime)); - } - - // Build the shell command line. Quote the inner command for the chosen shell. - config.process.commandLine = buildShellCommandLine(shell, command, argv); - if (req.cwd) config.process.cwd = req.cwd; - diag('sandbox process command prepared', { - shell, - commandLength: command.length, - argCount: argv.length, - cwd: config.process.cwd || null, - timeoutMs: req.timeoutMs, - }); - - // Override TEMP/TMP/TMPDIR so commands inside the sandbox write to our - // scratch dir, not the user's real %TEMP% (which we stripped above). - // New-TemporaryFile, mkdtemp(), etc. all respect these. - config.process.env = buildSandboxEnv(req.env, scratchDir); - const sdkTimeoutMs = req.timeoutMs > 0 ? req.timeoutMs : 30000; - config.process.timeout = sdkTimeoutMs; - diag('sandbox process env prepared', { - envKeys: config.process.env.map(e => String(e).split('=')[0]).sort(), - scratchDir, - sdkTimeoutMs, - }); - - // CRITICAL: usePty:false — the @microsoft/mxc-sdk default uses node-pty which - // conflates stdout/stderr and rounds exit codes through PTY signals. We want - // LocalCommandRunner-equivalent semantics here (separate streams, reliable - // exit code). - const spawnOptions = { - usePty: false, - debug: false, - }; - if (req.wxcExecPath) { - spawnOptions.executablePath = req.wxcExecPath; - } - diag('spawnSandboxFromConfig before', { - spawnOptions, - config: summarizeConfig(config), - effectivePolicy: summarizePolicy(policy), - }); - - const artifactPaths = writePreflightArtifacts(policy, config, spawnOptions); - diag('preflight artifacts written', artifactPaths); - - if (PREFLIGHT_ONLY) { - diag('spawnSandboxFromConfig skipped', { - reason: 'preflight-only mode; sandbox execution intentionally disabled', - artifactPaths, - }); - return emit({ - exitCode: 0, - stdout: - "MXC preflight completed; sandbox execution intentionally skipped.\n" + - `Effective policy: ${artifactPaths.effectivePolicyPath}\n` + - `Container config: ${artifactPaths.configPath}\n` + - `Spawn options: ${artifactPaths.spawnOptionsPath}\n`, - stderr: '', - timedOut: false, - durationMs: Math.max(0, Date.now() - startTime), - containmentTag: 'mxc-preflight', - }); - } - - let child; - try { - child = spawnSandboxFromConfig(config, spawnOptions); - diag('spawnSandboxFromConfig after', { - pid: child && typeof child.pid === 'number' ? child.pid : null, - stdout: Boolean(child?.stdout), - stderr: Boolean(child?.stderr), - }); - } catch (e) { - diag('spawnSandboxFromConfig failed', { error: e.message, config: summarizeConfig(config) }); - return emit(failResponse(-1, `spawnSandboxFromConfig failed: ${e.message}`, startTime)); - } - - let stdout = ''; - let stderr = ''; - let stdoutBytes = 0; - let stderrBytes = 0; - let truncated = false; - - child.stdout?.on('data', (chunk) => { - const text = chunk.toString(); - if (stdoutBytes + text.length > callerMaxOutput) { - stdout += text.substring(0, callerMaxOutput - stdoutBytes); - stdoutBytes = callerMaxOutput; - truncated = true; - } else { - stdout += text; - stdoutBytes += text.length; - } - }); - child.stderr?.on('data', (chunk) => { - const text = chunk.toString(); - if (stderrBytes + text.length > callerMaxOutput) { - stderr += text.substring(0, callerMaxOutput - stderrBytes); - stderrBytes = callerMaxOutput; - truncated = true; - } else { - stderr += text; - stderrBytes += text.length; - } - }); - - const exitCode = await new Promise((resolve) => { - child.on('close', (code) => resolve(code ?? -1)); - child.on('error', (err) => { - stderr += `\n[bridge] spawn error: ${err.message}`; - resolve(-1); - }); - }); - - if (truncated) { - stderr += `\n[bridge] output truncated at ${callerMaxOutput} bytes`; - } - - // Heuristic for SDK-level timeout: if elapsed >= the timeout we passed to the - // SDK and the child exited non-zero, we treat it as a timeout. The SDK kills - // the child on timeout but doesn't surface a distinct exit code, so this is - // the cleanest signal available without poking SDK internals. - const durationMs = Date.now() - startTime; - const timedOut = exitCode !== 0 && durationMs >= sdkTimeoutMs; - diag('sandbox child closed', { - exitCode, - durationMs, - timedOut, - stdoutBytes, - stderrBytes, - truncated, - }); - - emit({ - exitCode, - stdout, - stderr, - timedOut, - durationMs, - containmentTag: 'mxc', - }); - } finally { - // Best-effort scratch cleanup. If the user's command spawned a detached - // process that's still using the dir we may fail here — that's fine, the - // OS will reap it eventually since these live under %TEMP%. - if (scratchDir) { - try { fs.rmSync(scratchDir, { recursive: true, force: true }); } catch { /* ignore */ } - } - } -} - -function mergePolicy(callerPolicy, tools, temp) { - const fs0 = callerPolicy?.filesystem ?? {}; - return { - version: callerPolicy?.version ?? '0.4.0-alpha', - filesystem: { - readonlyPaths: dedupe([ - ...(fs0.readonlyPaths ?? []), - ...(tools?.readonlyPaths ?? []), - ]), - readwritePaths: dedupe([ - ...(fs0.readwritePaths ?? []), - ...(temp?.readwritePaths ?? []), - ]), - deniedPaths: fs0.deniedPaths ?? [], - clearPolicyOnExit: fs0.clearPolicyOnExit ?? true, - }, - network: callerPolicy?.network ?? { allowOutbound: false, allowLocalNetwork: false }, - ui: callerPolicy?.ui ?? { allowWindows: false, clipboard: 'none', allowInputInjection: false }, - timeoutMs: callerPolicy?.timeoutMs, - }; -} - -function dedupe(arr) { - return Array.from(new Set(arr.filter(Boolean))); -} - -const BASE_ENV_ALLOWLIST = [ - 'ALLUSERSPROFILE', - 'APPDATA', - 'ComSpec', - 'CommonProgramFiles', - 'CommonProgramFiles(x86)', - 'CommonProgramW6432', - 'HOMEDRIVE', - 'HOMEPATH', - 'LOCALAPPDATA', - 'NUMBER_OF_PROCESSORS', - 'OS', - 'PATH', - 'PATHEXT', - 'PROCESSOR_ARCHITECTURE', - 'PROCESSOR_IDENTIFIER', - 'PROCESSOR_LEVEL', - 'PROCESSOR_REVISION', - 'ProgramData', - 'ProgramFiles', - 'ProgramFiles(x86)', - 'ProgramW6432', - 'PUBLIC', - 'SystemDrive', - 'SystemRoot', - 'USERDOMAIN', - 'USERNAME', - 'USERPROFILE', - 'windir', -]; - -function buildSandboxEnv(requestEnv, scratchDir) { - const env = {}; - for (const name of BASE_ENV_ALLOWLIST) { - if (Object.prototype.hasOwnProperty.call(process.env, name)) { - env[name] = process.env[name]; - } - } - - if (requestEnv && typeof requestEnv === 'object') { - for (const [name, value] of Object.entries(requestEnv)) { - if (isRequestEnvNameBlocked(name) || value == null) continue; - env[name] = String(value); - } - } - - env.TEMP = scratchDir; - env.TMP = scratchDir; - env.TMPDIR = scratchDir; - return Object.entries(env).map(([k, v]) => `${k}=${v}`); -} - -function isRequestEnvNameBlocked(name) { - if (!name || /[=\0\r\n\s]/.test(name)) return true; - const upper = String(name).toUpperCase(); - if ([ - 'PATH', - 'PATHEXT', - 'COMSPEC', - 'PSMODULEPATH', - 'NODE_OPTIONS', - 'NODE_PATH', - 'PYTHONPATH', - 'PYTHONSTARTUP', - 'PYTHONUSERBASE', - 'RUBYOPT', - 'RUBYLIB', - 'PERL5OPT', - 'PERL5LIB', - 'PERLIO', - 'GIT_SSH', - 'GIT_SSH_COMMAND', - 'GIT_EXEC_PATH', - 'GIT_PROXY_COMMAND', - 'GIT_ASKPASS', - 'BASH_ENV', - 'ENV', - 'CDPATH', - 'PROMPT_COMMAND', - 'ZDOTDIR', - ].includes(upper)) return true; - if (upper.startsWith('LD_') || upper.startsWith('DYLD_')) return true; - return hasCredentialMarker(upper); -} - -function hasCredentialMarker(name) { - const segments = name.split(/[_\-.]/).filter(Boolean); - const has = (segment) => segments.includes(segment); - const hasPair = (first, second) => { - for (let i = 0; i < segments.length - 1; i++) { - if (segments[i] === first && segments[i + 1] === second) return true; - } - return false; - }; - return has('TOKEN') || - has('SECRET') || - has('PASSWORD') || - has('PASSWD') || - has('CREDENTIAL') || - has('CREDENTIALS') || - hasPair('API', 'KEY') || - hasPair('ACCESS', 'KEY') || - hasPair('PRIVATE', 'KEY') || - hasPair('CLIENT', 'SECRET') || - hasPair('CONNECTION', 'STRING') || - name.includes('CONNSTR'); -} - -function buildShellCommandLine(shell, command, argv) { - const sh = shell.toLowerCase(); - if (sh === 'cmd') { - // For cmd.exe, wrap the entire command line in outer quotes via /S /C. - // /S strips exactly the first and last `"` of the operand before parsing, - // so the inner content (already quoteArg-escaped for args) is passed - // through verbatim. DO NOT double-escape `"` here — quoteArg already - // doubles inner quotes per cmd's escape convention; running .replace on - // the concatenated string would quadruple them. - const argsSuffix = (argv && argv.length > 0) - ? ' ' + argv.map((a) => quoteArg(a, /*isCmd*/ true)).join(' ') - : ''; - const inner = command + argsSuffix; - return `cmd.exe /S /C "${inner}"`; - } - // PowerShell variants: use -EncodedCommand with UTF-16LE Base64. PowerShell - // decodes it back into a single command expression that is NOT subject to - // outer command-line metacharacter interpretation. This is the most robust - // way to pass an agent-supplied command without leaking control characters - // (`;`, `|`, `&`, etc.) into the outer cmdline parser. - const argsSuffix = (argv && argv.length > 0) - ? ' ' + argv.map((a) => quoteArg(a, /*isCmd*/ false)).join(' ') - : ''; - const psExpression = command + argsSuffix; - const encoded = Buffer.from(psExpression, 'utf16le').toString('base64'); - if (sh === 'pwsh') { - return `pwsh.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`; - } - return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`; -} - -// Shell metacharacters whose presence forces quoting. Matches the set used by -// OpenClaw.Shared.ShellQuoting.NeedsQuoting on the C# side so the bridge has -// the same quoting behavior as LocalCommandRunner. Quoting a switch like -// `-Name` would break PowerShell parameter binding (PowerShell sees it as a -// string literal, not a parameter) — we conditional-quote like the host does. -const SHELL_METACHARS = /[ \t"'&|;<>()^%!$`*?\[\]{}~\n\r]/; - -function needsQuoting(arg) { - // Empty string needs explicit quotes so it's preserved as an argv element. - if (arg === '' || arg == null) return true; - return SHELL_METACHARS.test(arg); -} - -function quoteArg(arg, isCmd) { - if (!needsQuoting(arg)) return String(arg); - // Minimal quoting; matches OpenClaw.Shared/ShellQuoting semantics for cmd - // (double-quote with escaped inner quotes) and PowerShell (single quotes). - if (isCmd) { - return `"${String(arg).replace(/"/g, '""')}"`; - } - return `'${String(arg).replace(/'/g, "''")}'`; -} - -function readJsonFromStdin() { - return new Promise((resolve, reject) => { - const chunks = []; - process.stdin.on('data', (c) => chunks.push(c)); - process.stdin.on('end', () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); - } catch (e) { - reject(e); - } - }); - process.stdin.on('error', reject); - }); -} - -function emit(response) { - process.stdout.write(JSON.stringify(response)); -} - -function diag(label, detail) { - const line = `[${new Date().toISOString()}] [openclaw-mxc-debug] ${label}: ${JSON.stringify(detail)}\n`; - try { - process.stderr.write(line); - } catch { - // Diagnostics must never affect sandbox execution. - } - try { - fs.mkdirSync(DIRECT_DEBUG_DIR, { recursive: true }); - fs.appendFileSync(DIRECT_DEBUG_LOG_PATH, line, 'utf8'); - } catch { - // Diagnostics must never affect sandbox execution. - } -} - -function writePreflightArtifacts(policy, config, spawnOptions) { - const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, ''); - const prefix = path.join(DIRECT_DEBUG_DIR, `openclaw-mxc-preflight-${stamp}-${process.pid}`); - const effectivePolicyPath = `${prefix}.effective-policy.json`; - const configPath = `${prefix}.container-config.json`; - const spawnOptionsPath = `${prefix}.spawn-options.json`; - - fs.mkdirSync(DIRECT_DEBUG_DIR, { recursive: true }); - fs.writeFileSync(effectivePolicyPath, JSON.stringify(policy, null, 2), 'utf8'); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); - fs.writeFileSync(spawnOptionsPath, JSON.stringify(spawnOptions, null, 2), 'utf8'); - - return { effectivePolicyPath, configPath, spawnOptionsPath }; -} - -function summarizePolicy(policy) { - const fsPolicy = policy?.filesystem ?? {}; - return { - version: policy?.version ?? null, - filesystem: { - readonlyPaths: fsPolicy.readonlyPaths ?? [], - readwritePaths: fsPolicy.readwritePaths ?? [], - deniedPaths: fsPolicy.deniedPaths ?? [], - clearPolicyOnExit: fsPolicy.clearPolicyOnExit ?? null, - }, - network: policy?.network ?? null, - ui: policy?.ui ?? null, - timeoutMs: policy?.timeoutMs ?? null, - }; -} - -function summarizeConfig(config) { - const processConfig = config?.process ?? {}; - return { - keys: config && typeof config === 'object' ? Object.keys(config).sort() : [], - policyPath: firstString(config, ['policyPath', 'policyFile', 'policyFilePath', 'configPath', 'configFilePath']), - process: { - commandLineLength: typeof processConfig.commandLine === 'string' ? processConfig.commandLine.length : 0, - cwd: processConfig.cwd ?? null, - timeout: processConfig.timeout ?? null, - envKeys: Array.isArray(processConfig.env) - ? processConfig.env.map(e => String(e).split('=')[0]).sort() - : [], - }, - }; -} - -function firstString(source, names) { - if (!source || typeof source !== 'object') return null; - for (const name of names) { - if (typeof source[name] === 'string') return source[name]; - } - return null; -} - -function failResponse(exitCode, errorMessage, startTime = Date.now()) { - return { - exitCode, - stdout: '', - stderr: errorMessage, - timedOut: false, - durationMs: Math.max(0, Date.now() - startTime), - containmentTag: 'mxc', - }; -} - -main().catch((err) => { - process.stdout.write(JSON.stringify({ - exitCode: -1, - stdout: '', - stderr: `[bridge] unhandled error: ${err && err.message ? err.message : String(err)}`, - timedOut: false, - durationMs: 0, - containmentTag: 'mxc', - })); - process.exit(0); // exit 0 so the host always sees our envelope, not a Node crash -});